Most of the application's backend logic resides in action files. Action code can modify the database, manage the requester's session, make API calls, and perform various other operations.
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.
The default create, update, and delete actions in Gadget handle basic record operations like creating, updating, and deleting records using utilities like applyParams, save, and deleteRecord. These actions can be extended with custom logic to manipulate records or perform additional tasks before saving or deleting. For example, you can add default values in the create action or log changes in the update action.
In addition to these default actions, if both create and update actions exist on a model, Gadget will also automatically include an upsert action. This action can be used to create or update records based on their unique fields. For more advanced usage, you can explore the upsert meta action, which offers more control over the upsert process.
Action file structure and exports
An action file can export four pieces: a run function, an onSuccess function, an options object, and a params object.
Only the run function is required — onSuccess, options, and params are optional.
The run function executes the main body of the action
The onSuccess function executes when the action's run function completes successfully
The params object configures the schema of any extra parameters the action accepts
The options object configures the action's details, like action type, transactionality and timeouts
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. In model actions the run function is transactional by default and requires that it completes within a 5 second timeout.
api/models/post/actions/create.js
JavaScript
import { save } from "gadget-server";
export const run: ActionRun = async ({ record }) => {
// Explicitly setting the title field of the record
record.title = "New Record Title";
await save(record);
};
import { save } from "gadget-server";
export const run: ActionRun = async ({ record }) => {
// Explicitly setting the title field of the record
record.title = "New Record Title";
await save(record);
};
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:
api/models/post/actions/create.js
JavaScript
// Importing necessary functions from the "gadget-server" library
import { save, applyParams, ActionRun, ActionOptions } from "gadget-server";
// The 'run' function is executed when the action is triggered
export const run: ActionRun = async ({ record, params }) => {
// Apply the incoming parameters to the record
applyParams(record, params);
// Save the updated record to the database
await save(record);
};
// Importing necessary functions from the "gadget-server" library
import { save, applyParams, ActionRun, ActionOptions } from "gadget-server";
// The 'run' function is executed when the action is triggered
export const run: ActionRun = async ({ record, params }) => {
// Apply the incoming parameters to the record
applyParams(record, params);
// Save the updated record to the database
await save(record);
};
This function does exactly what it sounds like - it saves any changes made to a record. However, it is important to differentiate what is done before and after save.
Generally, any modification you want to apply to the record should be done before save. This includes applyParams, or any actions or modifications that should be made.
If you make any modifications after save, you will need to call the function again to preserve these new changes, which can lead to poorer performance.
Additionally, any checks on the ability to run an action should be performed before save. For instance, you may want to check that you are within a billing plan's limit before saving anything that may cause you to exceed the plan and pay more.
However, if you are creating a new record, and want to modify it immediately afterwards, this modification will need to be done after the save, otherwise you would not have access to the new record's id to perform updates.
onSuccess function
The onSuccess function runs once the run function has executed successfully. It will not run if:
The run function throws an error.
Another action being executed as part of the same transaction throws an error.
It is not included and exported in the action file.
Errors in the onSuccess function will not rollback the transaction completed in the run function.
onSuccess functions are often useful for running business logic, like sending emails or charging a card, after your action's database work has completed successfully. Doing so ensures that important business logic is not prematurely triggered (e.g you avoid alerting your subscribers to a new blog post when the blog post record failed to save in the database).
For example, if an action triggers multiple related actions and one of them throws an uncaught error (i.e., not handled with try/catch or a .catch() on a promise), none of their onSuccess functions will execute. However, if errors are caught and handled by the user, the onSuccess functions can still run.
Lets view some examples to further demonstrate the onSuccess function's capabilities.
Custom cascading delete
In this example, a blog post has multiple images (a hasMany relationship). If either the blog post or any of its images fails to delete, the onSuccess function will not run.
api/models/blogPost/actions/delete.js
JavaScript
import { deleteRecord, ActionOptions } from "gadget-server";
// Define the 'run' function which handles the deletion of the blog post and its associated images
export const run: ActionRun = async ({ record, logger }) => {
/**
* Delete all images associated with the blog post by using the blogPostId (using the internal API).
* If this api call fails, the run function will throw an error, and stop the onSuccess function from running.
*/
await api.internal.blogPostImage.deleteMany({
filter: { blogPostId: record.id },
});
// Now delete the blog post itself
await deleteRecord(record);
};
// Define the 'onSuccess' function to run after the 'run' function completes successfully
export const onSuccess: ActionOnSuccess = async ({ record, logger }) => {
// Log a success message after the blog post is deleted
logger.info("Blog deleted");
};
export const options = {
actionType: "delete",
};
import { deleteRecord, ActionOptions } from "gadget-server";
// Define the 'run' function which handles the deletion of the blog post and its associated images
export const run: ActionRun = async ({ record, logger }) => {
/**
* Delete all images associated with the blog post by using the blogPostId (using the internal API).
* If this api call fails, the run function will throw an error, and stop the onSuccess function from running.
*/
await api.internal.blogPostImage.deleteMany({
filter: { blogPostId: record.id },
});
// Now delete the blog post itself
await deleteRecord(record);
};
// Define the 'onSuccess' function to run after the 'run' function completes successfully
export const onSuccess: ActionOnSuccess = async ({ record, logger }) => {
// Log a success message after the blog post is deleted
logger.info("Blog deleted");
};
export const options = {
actionType: "delete",
};
The run function deletes all images associated with the blog post first and then deletes the blog post itself. If either deletion fails (e.g., due to a database error or constraint violation), run throws an error, and onSuccess will not execute.
Why not just do everything in run?
While you can place all logic inside the run function, doing so can lead to longer wait times for users, especially when dealing with complex interactions or third-party API calls. Additionally, run has a timeout limit of 5 seconds, which cannot be increased.
By separating out logic that doesn't need to run immediately into onSuccess, you:
Improve performance by offloading non-critical tasks.
Ensure that external services or APIs are only notified after the database transaction has successfully completed, avoiding bugs where external systems might act on inconsistent data.
In summary, keep work in the database limited to run and business logic, such as sending emails or charging a card, in onSuccess to prevent premature business logic triggers.
Method
Usage Scenario
run
Use when you want to execute an action immediately or work on data in the database. Best for processing user input or event-based triggers.
onSuccess
Use when you need to execute a follow-up task only if the action completes successfully. Ideal for handling post-processing or making third-party API calls.
Adding parameters to actions
You can enhance your model-scoped or globally-scoped actions by adding new parameters. To do this, export a params object from your action file.
In this example, we are defining a new parameter called sendNotifications of type boolean. This parameter can be accessed within the run function of your action, which is used to handle immediate tasks, like database interactions. If the task requires additional steps to be performed after the action has successfully completed, such as sending notifications, you can use the onSuccess function.
By structuring your logic this way, you ensure that time-consuming or non-essential tasks (such as sending notifications) are handled in the onSuccess function, improving performance and keeping your run function focused on critical operations.
Gadget expects the exported params object to adhere to a subset of the JSON schema specification. Currently, the JSON schema types supported for both model-scoped and globally-scoped actions are object, string, integer, number, boolean, and array.
Only these primitive types are currently supported by Gadget. Other JSON schema features like validations, required, and schema
composition primitives (allOf) are not yet available. If you need to validate parameter values, you can implement this logic within your
action's code.
By default, globally-scoped actions do not have any parameters. You'll often need to define your own params object to accept input.
Let's imagine you're building a tool that processes widgets. You might want a globally-scoped action to handle this, allowing users to provide a name (foo) and a quantity (bar). Here's how you'd define the parameters:
api/globalActions/processWidgets.js
JavaScript
// Define parameters for the globally-scoped action
export const params = {
foo: { type: "string" },
bar: { type: "number" },
};
export const run: ActionRun = async ({ params, logger, api, connections }) => {
// Access the 'foo' and 'bar' parameters here
logger.info({ foo: params.foo, bar: params.bar }, "This is foobar");
// ... your action logic
};
// Define parameters for the globally-scoped action
export const params = {
foo: { type: "string" },
bar: { type: "number" },
};
export const run: ActionRun = async ({ params, logger, api, connections }) => {
// Access the 'foo' and 'bar' parameters here
logger.info({ foo: params.foo, bar: params.bar }, "This is foobar");
// ... your action logic
};
This globally-scoped action, named processWidgets in this example, can be called with the defined parameters like this:
JavaScript
// Calling the globally-scoped action with parameters
await api.processWidgets({ foo: "hello", bar: 10 });
// Calling the globally-scoped action with parameters
await api.processWidgets({ foo: "hello", bar: 10 });
Adding parameters to model-scoped actions
Model-scoped actions automatically include a set of parameters based on the action type and the model's fields. You can add additional params for custom behavior or when you need parameters that are not directly stored on the model. This is particularly useful for custom model-scoped actions.
For instance, let's say you have a Student model and you want to create a custom action to suspend a student for a specific duration. You can add a suspensionLength parameter:
api/models/student/actions/suspend.js
JavaScript
import { applyParams, save, ActionOptions } from "gadget-server";
import { getUpdatedDate } from "../utils";
// Define a new parameter for the suspend action
export const params = {
suspensionLength: { type: "number" },
};
export const run: ActionRun = async ({
params,
record,
logger,
api,
connections,
}) => {
applyParams(params, record);
// Mark the student as suspended
record.isSuspended = true;
// Schedule a background action to un-suspend the student
await api.enqueue(
api.student.update,
{
id: record.id,
isSuspended: false,
},
{
startAt: getUpdatedDate(params.suspensionLength),
}
);
};
export const options: ActionOptions = {
actionType: "custom",
};
import { applyParams, save, ActionOptions } from "gadget-server";
import { getUpdatedDate } from "../utils";
// Define a new parameter for the suspend action
export const params = {
suspensionLength: { type: "number" },
};
export const run: ActionRun = async ({
params,
record,
logger,
api,
connections,
}) => {
applyParams(params, record);
// Mark the student as suspended
record.isSuspended = true;
// Schedule a background action to un-suspend the student
await api.enqueue(
api.student.update,
{
id: record.id,
isSuspended: false,
},
{
startAt: getUpdatedDate(params.suspensionLength),
}
);
};
export const options: ActionOptions = {
actionType: "custom",
};
This custom suspend action for a Student record can be called with the suspensionLength parameter:
JavaScript
// Calling the model-scoped action with the custom parameter
await api.student.suspend("123", { suspensionLength: 3 });
// Calling the model-scoped action with the custom parameter
await api.student.suspend("123", { suspensionLength: 3 });
Adding Object parameters
For more complex scenarios, you can define parameters of type object. This allows you to accept structured data as input to your actions.
The object param type uses the properties key to define the expected fields within the object and their respective types.
Here's an example where we define a fullName object, which contains first and last name strings:
With this definition, you can make API calls with the fullName object parameter:
JavaScript
// Calling the action with an object parameter
await api.myAction({ fullName: { first: "Jane", last: "Doe" } });
// Calling the action with an object parameter
await api.myAction({ fullName: { first: "Jane", last: "Doe" } });
Using the properties key is the preferred method for defining object parameters, as it allows for your api to remain strongly typed. However, if you want to add a parameter that allows for an arbitrary object shape, you can use the additionalProperties key.
JavaScript
// define a data object param that allows for any object shape
export const params = {
data: { type: "object", additionalProperties: true },
};
export const run: ActionRun = async ({ params, logger }) => {
// params.data will be an object with type Record<string, unknown>
logger.info({ data: params.data });
};
// define a data object param that allows for any object shape
export const params = {
data: { type: "object", additionalProperties: true },
};
export const run: ActionRun = async ({ params, logger }) => {
// params.data will be an object with type Record<string, unknown>
logger.info({ data: params.data });
};
Adding array parameters
Arrays can be added as parameters to actions, allowing for more complex inputs.
The array param type requires an items key with a mapping of the items in the array to their types. Each item in the array can be a primitive type or an object.
For example, if you want to allow a list of customerIds to be passed into an action:
api/models/order/actions/create.js
JavaScript
export const params = {
customerIds: { type: "array", items: { type: "string" } },
};
export const run: ActionRun = async ({ record, params }) => {
// params.customerIds will be an array of strings
for (const customerId of params.customerIds) {
// ... do something with each customer id
}
};
export const params = {
customerIds: { type: "array", items: { type: "string" } },
};
export const run: ActionRun = async ({ record, params }) => {
// params.customerIds will be an array of strings
for (const customerId of params.customerIds) {
// ... do something with each customer id
}
};
With that in place in your action code, you can now make the following API call:
Action options provide configurations for how your action is executed. Some of these options, such as triggers, can be set in the Gadget GUI. The rest may be set within the action code. Generally, those that can be set within action code are used less frequently.
Action options will be found at the bottom of the default action file and provide configuration settings for how your action is executed.
actionType
The actionType option set for each action determines how a model-scoped action is exposed in the GraphQL API.
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// mark this action as a record creator
actionType: "create",
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// 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 do not 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 do not accept any parameters. Deletes do not 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.
Globally-scoped actions do not have an actionType option because they do not need to specify whether an id is required (as is the case with model actions that typically deal with record mutations like create, update, or delete). This distinction is unnecessary for globally-scoped actions, which are more generalized and are not tied to a specific record context. However, both globally-scoped and model-scoped actions are still 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 for apps using earlier versions. Starting with the 1.4 release, the default timeout will change to 15 seconds. However, the timeout may vary depending on which version of the app is in use.
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// allow this action to take 1s before throwing a GGT_TIMEOUT_ERROR
timeoutMS: 1000,
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// allow this action to take 1s before throwing a GGT_TIMEOUT_ERROR
timeoutMS: 1000,
};
transactional
The transactional option specifies whether the action's run function should execute within a database transaction. For model-scoped actions, transactionality is set to true by default, meaning all operations in the run function are wrapped in a transaction. If any operation fails, the entire transaction is rolled back to maintain consistency.
You should keep the run function transactional when you want all operations to be atomic to ensure data integrity. However, you can use transactional: false when the operations are independent, and a failure in one should not affect the others (e.g., for non-database operations).
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// Disable transaction for this action
transactional: false,
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// Disable transaction for this action
transactional: false,
};
returnType
Specifies whether the action should return a custom result from the run function. Defaults to false for model-scoped actions and true for globally-scoped actions.
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// enable the ability to return a value from the `run` function of the action
returnType: true,
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
// enable the ability to return a value from the `run` function of the action
returnType: true,
};
When you set returnType to true, it allows the run function to return a value, which can be useful when you need to send feedback or results back to the caller immediately after the action has been executed. This is particularly helpful for scenarios where you want to confirm the success of an operation, return computed results, or provide data for further use, without relying on external systems or triggers.
Let's take a look at a simple example of how to return a value from a run function when setting returnType to true.
api/models/someModel/actions/someAction.js
JavaScript
import { applyParams, save, ActionOptions } from "gadget-server";
export const run: ActionRun = async ({ params, record, logger, api }) => {
applyParams(params, record);
await save(record);
return {
// returning an object
result: "successfully returned",
};
};
export const onSuccess: ActionOnSuccess = async ({
params,
record,
logger,
api,
}) => {
// cannot return from onSuccess!
};
export const options: ActionOptions = {
actionType: "custom",
// Setting returnType to `true` as it is false by default on a model-scoped action
returnType: true,
};
import { applyParams, save, ActionOptions } from "gadget-server";
export const run: ActionRun = async ({ params, record, logger, api }) => {
applyParams(params, record);
await save(record);
return {
// returning an object
result: "successfully returned",
};
};
export const onSuccess: ActionOnSuccess = async ({
params,
record,
logger,
api,
}) => {
// cannot return from onSuccess!
};
export const options: ActionOptions = {
actionType: "custom",
// Setting returnType to `true` as it is false by default on a model-scoped action
returnType: true,
};
triggers
Specifies which triggers cause the action to execute. Triggers can be added via code or via Gadget UI
API endpoint trigger
The API endpoint trigger is not shown within options but by default is true in every model-scoped action.
This applies to all model-scoped actions, except for the default Shopify ones. However, if you create a custom model-scoped action on a Shopify-related model, it will include an API endpoint trigger.
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
actionType: "update",
// API trigger will not be shown by default on newly created actions
triggers: {},
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
actionType: "update",
// API trigger will not be shown by default on newly created actions
triggers: {},
};
If you choose to remove the trigger you'll need to explicitly set the API endpoint trigger to false
api/models/someModel/actions/someAction.js
JavaScript
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
actionType: "update",
triggers: {
// Pass in `api` and set it to false
api: false,
},
};
import { ActionOptions } from "gadget-server";
export const options: ActionOptions = {
actionType: "update",
triggers: {
// Pass in `api` and set it to false
api: false,
},
};
Writing action code
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.
Default actions
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
export const run: ActionRun = async ({ record }) => {
record.startDate ??= new Date();
// ... rest of the action
};
export const run: ActionRun = async ({ record }) => {
record.startDate ??= new Date();
// ... 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.
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.
JavaScript
// This will upsert the record based on the 'uniqueCode' field
await api.gizmo.upsert({
name: "XZ-77",
uniqueCode: "112233",
on: ["uniqueCode"],
});
// This will upsert the record based on the 'uniqueCode' field
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.
JavaScript
// This will upsert the record based on the 'uniqueCode' field and the 'user' relationship
await api.gizmo.upsert({
name: "XZ-77",
code: "112233",
user: {
_link: "1",
},
on: ["code", "user"],
});
// This will upsert the record based on the 'uniqueCode' field and the 'user' relationship
await api.gizmo.upsert({
name: "XZ-77",
code: "112233",
user: {
_link: "1",
},
on: ["code", "user"],
});
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:
Transactionality is on by default for model action run functions, and off by default for global 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.
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 accepts 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();
}
});
};
For transactionality and rollbacks to take effect, api calls must be made using the Internal
API.
GadgetTransaction callback params
The following are parameters of 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
};
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.
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
Nested actions and execution order
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
{
"_converge": {
"values": [
// will update record with id 1
{ "id": "1", "name": "first" },
// will create a new record
{ "name": "second" }
// other records not present in this list will be deleted
]
}
}
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
{
"_converge": {
"values": [
// ...
],
"actions": {
// will create any new records using the publicCreate action of the child model
"create": "publicCreate",
// will update any existing records using the specialUpdate action of the child model
"update": "specialUpdate"
}
}
}
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.
Action timeouts
Gadget actions have a default maximum execution duration of 3 minutes . Time spent executing the run and onSuccess functions must take less than 3 minutes by default, or Gadget will abort the action's execution. Actions that run for longer than 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 minutes timeout.
Action timeouts can be changed using the timeoutMS action option:
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.
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.
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 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
// 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",
});
}
};
// 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(),
});
};
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-scoped and globally-scoped actions, 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.<actionName>. 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.
Using api.actAsSession
By default, inside action code, the api object will have system admin privileges. This means inside your actions, the api object can use any api endpoints without any restrictions. This is very helpful for implementing the internal logic of your app.
However, there are times where you may want the api object to act with the same permissions as the session making the request. In this case you can use api.actAsSession, here is an example:
this case publishedPosts will be the same as unauthenticatedAccessiblePosts
JavaScript
export const run: ActionRun = async ({ api }) => {
// assume that this action is called from an unauthenticated session
// by default api has admin privileges, so this will be all posts
const allPosts = await api.post.findMany();
// assume that there is a filter on the unauthenticated role's read access to post
const unauthenticatedAccessiblePosts = await api.actAsSession.post.findMany();
};
export const run: ActionRun = async ({ api }) => {
// assume that this action is called from an unauthenticated session
// by default api has admin privileges, so this will be all posts
const allPosts = await api.post.findMany();
// assume that there is a filter on the unauthenticated role's read access to post
const unauthenticatedAccessiblePosts = await api.actAsSession.post.findMany();
};
api.actAsAdmin
The api object also supports reversing any current session permissions by calling api.actAsAdmin(). This is useful if you need to escape session permissions in a context where the api client may have been created with session permissions, like if the client has had .actAsSession() called on it.
api/actions/getPublishedPosts.js
JavaScript
export const run: ActionRun = async ({ api }) => {
// get a session api client
const sessionApi = await api.actAsSession();
// do some stuff with the session's privileges
// ...
// later, go back to the admin api
const backToAdminApi = await sessionApi.actAsAdmin();
// do some stuff with sysadmin privileges
// ...
};
export const run: ActionRun = async ({ api }) => {
// get a session api client
const sessionApi = await api.actAsSession();
// do some stuff with the session's privileges
// ...
// later, go back to the admin api
const backToAdminApi = await sessionApi.actAsAdmin();
// do some stuff with sysadmin privileges
// ...
};
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
Tracking whether an action has already been processed using a flag prevents duplicate execution:
JavaScript
await api.transaction(async (transaction) => {
const order = await api.order.findById(456);
if (!order.processed) {
await api.internal.order.update(order.id, {
processed: true, // Flag to prevent re-execution
});
// Perform action only once
await triggerAnotherAction(order);
}
});
await api.transaction(async (transaction) => {
const order = await api.order.findById(456);
if (!order.processed) {
await api.internal.order.update(order.id, {
processed: true, // Flag to prevent re-execution
});
// Perform action only once
await triggerAnotherAction(order);
}
});
Here, the processed flag ensures that the logic runs only once.
Using Rate Limits and Timeouts
Slowing down execution with a timeout prevents actions from running too frequently:
JavaScript
setTimeout(async () => {
await api.internal.order.update("789", {
status: "pending",
});
}, 1000); // Wait 1 second before retrying
setTimeout(async () => {
await api.internal.order.update("789", {
status: "pending",
});
}, 1000); // Wait 1 second before retrying
Debugging with Logging
Logging the execution times of an action helps detect infinite loops:
If excessive logs appear for the same action, it indicates a loop.
Model-scoped vs globally-scoped actions
In Gadget, there are 2 kinds of actions: model-scoped and globally-scoped. Model-scoped actions are defined on a specific model, while globally-scoped actions are not tied to any model. Model-scoped actions are typically used to perform operations tied to a specific record, while globally-scoped actions are more generalized application logic.