Action code
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!
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ api, record, params }) => {4 await applyParams(record, params);5 await save(record);6};78export const options: ActionOptions = {9 actionType: "update",10};
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ api, record, params }) => {4 await applyParams(record, params);5 await save(record);6};78export const options: ActionOptions = {9 actionType: "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'srun
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.
1import { save } from "gadget-server";23export const run: ActionRun = async ({ record }) => {4 record.title ??= "New Record Title";5 await save(record);6};
1import { save } from "gadget-server";23export const run: ActionRun = async ({ record }) => {4 record.title ??= "New Record Title";5 await save(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:
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params }) => {4 applyParams(record, params);5 await save(record);6};
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params }) => {4 applyParams(record, params);5 await save(record);6};
For more on the context object passed to actions, see the gadget-server
reference.
onSuccess
function
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.
1import { save, applyParams, ActionOptions } from "gadget-server";23export const onSuccess: ActionOnSuccess = async ({ record, params, logger }) => {4 logger.info({ record, params }, "post successfully created!");5};67export const options: ActionOptions = {8 actionType: "create",9};
1import { save, applyParams, ActionOptions } from "gadget-server";23export const onSuccess: ActionOnSuccess = async ({ record, params, logger }) => {4 logger.info({ record, params }, "post successfully created!");5};67export const options: ActionOptions = {8 actionType: "create",9};
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.
1import twilio from "some-twilio-client";23export const run: ActionRun = async ({ record }) => {4 // ...5};67export const onSuccess: ActionOnSuccess = async ({ record }) => {8 await twilio.sendSMS({9 to: record.phoneNumber,10 from: "+11112223333",11 body: "Notification from a Gadget app!",12 });13};
1import twilio from "some-twilio-client";23export const run: ActionRun = async ({ record }) => {4 // ...5};67export const onSuccess: ActionOnSuccess = async ({ record }) => {8 await twilio.sendSMS({9 to: record.phoneNumber,10 from: "+11112223333",11 body: "Notification from a Gadget app!",12 });13};
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:
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 try {5 await callUnreliableAPI();6 } catch (error) {7 logger.error({ error }, "error encountered");8 }9 // continue processing ...10};
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 try {5 await callUnreliableAPI();6 } catch (error) {7 logger.error({ error }, "error encountered");8 }9 // continue processing ...10};
Transaction rollback from errors
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:
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 // ...5};67export const options: ActionOptions = {8 // disable transactions for this action9 transactional: false,10};
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 // ...5};67export const options: ActionOptions = {8 // disable transactions for this action9 transactional: false,10};
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.
api.transaction()
transaction: <T>(callback: (transaction: GadgetTransaction) => Promise<T>) => Promise<T>
You can start custom transactions in contexts that don't already have a transaction, like in action onSuccess
functions or HTTP routes. To wrap a group of Internal API calls in a transaction, use the api.transaction
function.
1export const onSuccess: ActionOnSuccess = async ({2 record,3 params,4 logger,5 api,6}) => {7 await api.transaction(async ({ client, rollback }) => {8 // Example of a rollback use case9 try {10 await client.internal.someModel.create({ name: "new record" });11 // Error occurs in call to external service12 await fetch("https://example.com/api", {13 method: "POST",14 body: JSON.stringify({ name: "new record" }),15 });16 } catch (error) {17 logger.error({ error }, "error encountered");18 // Rollback the transaction19 await rollback();20 }21 });22};
1export const onSuccess: ActionOnSuccess = async ({2 record,3 params,4 logger,5 api,6}) => {7 await api.transaction(async ({ client, rollback }) => {8 // Example of a rollback use case9 try {10 await client.internal.someModel.create({ name: "new record" });11 // Error occurs in call to external service12 await fetch("https://example.com/api", {13 method: "POST",14 body: JSON.stringify({ name: "new record" }),15 });16 } catch (error) {17 logger.error({ error }, "error encountered");18 // Rollback the transaction19 await rollback();20 }21 });22};
GadgetTransaction
callback params
The following params are available in the callback
function:
Parameter | Type | Description |
---|---|---|
client | GadgetClient | A Gadget API client object. This is 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:
export const onSuccess: ActionOnSuccess = async ({ record, api }) => {// ... do some workawait api.enqueue(api.someModel.someAction, { id: record.id });// ... do some more work};
export const onSuccess: ActionOnSuccess = async ({ record, api }) => {// ... do some workawait api.enqueue(api.someModel.someAction, { id: record.id });// ... do some more work};
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:
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 // fast stuff within 5s transaction timeout5};67export const onSuccess: ActionOnSuccess = async ({ record, params, logger }) => {8 // slow stuff9};1011export const options: ActionOptions = {12 // 10 minutes (600,000 milliseconds) for a really long action13 timeoutMS: 600000,14};
1import { save, applyParams } from "gadget-server";23export const run: ActionRun = async ({ record, params, logger }) => {4 // fast stuff within 5s transaction timeout5};67export const onSuccess: ActionOnSuccess = async ({ record, params, logger }) => {8 // slow stuff9};1011export const options: ActionOptions = {12 // 10 minutes (600,000 milliseconds) for a really long action13 timeoutMS: 600000,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 theapplyParams
function - Saves the
record
instance to the database with thesave
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:
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-inid
parameter - Applies incoming parameters from the GraphQL mutation to the
record
instance with theapplyParams
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-inid
parameter - Permanently deletes the
record
from the database with thedeleteRecord
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. 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.
await api.gizmo.upsert({id: "123",name: "XZ-77",uniqueCode: "112233",});
await api.gizmo.upsert({id: "123",name: "XZ-77",uniqueCode: "112233",});
await api.gizmo.upsert({id: "123",name: "XZ-77",uniqueCode: "112233",});
await api.gizmo.upsert({id: "123",name: "XZ-77",uniqueCode: "112233",});
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.
1// This will upsert the record based on the 'uniqueCode' field2await api.gizmo.upsert({3 name: "XZ-77",4 uniqueCode: "112233",5 on: ["uniqueCode"],6});
1// This will upsert the record based on the 'uniqueCode' field2await api.gizmo.upsert({3 name: "XZ-77",4 uniqueCode: "112233",5 on: ["uniqueCode"],6});
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.
1// This will upsert the record based on the 'uniqueCode' field and the 'user' relationship2await api.gizmo.upsert({3 name: "XZ-77",4 code: "112233",5 user: {6 _link: "1",7 },8 on: ["code", "user"],9});
1// This will upsert the record based on the 'uniqueCode' field and the 'user' relationship2await api.gizmo.upsert({3 name: "XZ-77",4 code: "112233",5 user: {6 _link: "1",7 },8 on: ["code", "user"],9});
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.
export const run: ActionRun = async ({ api, params, logger, ...others }) => {// ...};
export const run: ActionRun = async ({ api, params, logger, ...others }) => {// ...};
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.
1export const run: ActionRun = async ({ record, logger }) => {2 // will log the record's id3 logger.info(record.id);4 // will log the value of the firstName field loaded from the database5 logger.info(record.firstName);6};
1export const run: ActionRun = async ({ record, logger }) => {2 // will log the record's id3 logger.info(record.id);4 // will log the value of the firstName field loaded from the database5 logger.info(record.firstName);6};
You can change properties of the record like normal JavaScript objects:
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
:
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:
1// example: send a notification to the user who owns the record if they've opted in2import twilio from "../../twilio-client";34export const run: ActionRun = async ({ api, record }) => {5 const creator = await api.user.findOne(record.creator._link);6 if (creator.wantsNotifications) {7 await twilio.sendSMS({8 to: creator.phoneNumber,9 from: "+11112223333",10 body: "Notification: " + record.title + "was updated",11 });12 }13};
1// example: send a notification to the user who owns the record if they've opted in2import twilio from "../../twilio-client";34export const run: ActionRun = async ({ api, record }) => {5 const creator = await api.user.findOne(record.creator._link);6 if (creator.wantsNotifications) {7 await twilio.sendSMS({8 to: creator.phoneNumber,9 from: "+11112223333",10 body: "Notification: " + record.title + "was updated",11 });12 }13};
Or, you can use the api
object to change other data in your application as a result of the current action:
1// example: save an audit log record when this record changes2export const run: ActionRun = async ({ api, record, model }) => {3 const changes = record.changes();45 // run a nested `create` action on the `auditLog` model6 await api.auditLogs.create({7 action: "Update",8 model: model.apiIdentifier,9 record: { _link: record.id },10 changes: changes.toJSON(),11 });12};
1// example: save an audit log record when this record changes2export const run: ActionRun = async ({ api, record, model }) => {3 const changes = record.changes();45 // run a nested `create` action on the `auditLog` model6 await api.auditLogs.create({7 action: "Update",8 model: model.apiIdentifier,9 record: { _link: record.id },10 changes: 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.
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:
1export const run: ActionRun = async ({ api }) => {2 // assume that this action is called from an unauthenticated session34 // by default api has admin privileges, so this will be all posts5 const allPosts = await api.post.findMany();67 // if we only want published posts, using api we need to filter for them8 const publishedPosts = await api.post.findMany({9 filter: {10 published: {11 equals: true,12 },13 },14 });1516 // assume that there is a filter on the unauthenticated role's read access to post17 // in this case publishedPosts will be the same as unauthenticatedAccessiblePosts18 const unauthenticatedAccessiblePosts = await api.actAsSession.post.findMany();19};
1export const run: ActionRun = async ({ api }) => {2 // assume that this action is called from an unauthenticated session34 // by default api has admin privileges, so this will be all posts5 const allPosts = await api.post.findMany();67 // if we only want published posts, using api we need to filter for them8 const publishedPosts = await api.post.findMany({9 filter: {10 published: {11 equals: true,12 },13 },14 });1516 // assume that there is a filter on the unauthenticated role's read access to post17 // in this case publishedPosts will be the same as unauthenticatedAccessiblePosts18 const unauthenticatedAccessiblePosts = await api.actAsSession.post.findMany();19};
api.actsAsSession
is only available when your action or route has been triggered by a call from a client that is using session authentication. Gadget uses session authentication by default for clients created in the browser, but for clients authenticating with an API Key, .actsAsSession
will throw an error. You can use the action context to know if there is a session available:
export const run: ActionRun = async ({ api, session }) => {if (session) {const widgets = await api.actAsSession.widget.findMany();}};
export const run: ActionRun = async ({ api, session }) => {if (session) {const widgets = await api.actAsSession.widget.findMany();}};
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 ofapi.someModel.update
) to make changes, skipping the action that re-triggers 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.
See Action infinite loops for strategies to break this infinite loop.
logger
Each action is passed a logger
object conforming to the logger
API for logging to Gadget's built-in Log Viewer.
1export const run: ActionRun = async ({ api, record, logger }) => {2 if (!record.someField) {3 logger.error({ record }, "record is missing required field for processing");4 } else {5 logger.info({ recordID: record.id }, "processing record");6 doImportantThingWithRecord(record);7 }8};
1export const run: ActionRun = async ({ api, record, logger }) => {2 if (!record.someField) {3 logger.error({ record }, "record is missing required field for processing");4 } else {5 logger.info({ recordID: record.id }, "processing record");6 doImportantThingWithRecord(record);7 }8};
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.
export const run: ActionRun = async ({ api, record, trigger, logger }) => {// will log an object like { "type": "api", "rootModel": "someModel", "rootAction": "someAction", etc ... }logger.info(trigger);};
export const run: ActionRun = async ({ api, record, trigger, logger }) => {// will log an object like { "type": "api", "rootModel": "someModel", "rootAction": "someAction", etc ... }logger.info(trigger);};
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:
1export const run: ActionRun = async ({ api, record, connections }) => {2 const shopifyClient = await connections.shopify.forShopDomain(3 "best-sneakers.myshopify.com"4 );56 await shopifyClient.graphql(7 `mutation ($input: ProductInput!) {8 productUpdate(input: $input) {9 product {10 id11 }12 userErrors {13 message14 }15 }16 }`,17 {18 input: {19 id: `gid://shopify/Product/${productId}`,20 ...productParams,21 },22 }23 );24};
1export const run: ActionRun = async ({ api, record, connections }) => {2 const shopifyClient = await connections.shopify.forShopDomain(3 "best-sneakers.myshopify.com"4 );56 await shopifyClient.graphql(7 `mutation ($input: ProductInput!) {8 productUpdate(input: $input) {9 product {10 id11 }12 userErrors {13 message14 }15 }16 }`,17 {18 input: {19 id: `gid://shopify/Product/${productId}`,20 ...productParams,21 },22 }23 );24};
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.
1export const onSuccess: ActionOnSuccess = async ({ api, record, connections }) => {2 // only do the remote update if the body field has changed3 if (record.changed("body")) {4 const shopifyClient = await connections.shopify.forShopDomain(5 "best-sneakers.myshopify.com"6 );78 await shopifyClient.graphql(9 `mutation ($input: ProductInput!) {10 productUpdate(input: $input) {11 product {12 id13 }14 userErrors {15 message16 }17 }18 }`,19 {20 input: {21 id: `gid://shopify/Product/${productId}`,22 ...productParams,23 },24 }25 );26 }27};
1export const onSuccess: ActionOnSuccess = async ({ api, record, connections }) => {2 // only do the remote update if the body field has changed3 if (record.changed("body")) {4 const shopifyClient = await connections.shopify.forShopDomain(5 "best-sneakers.myshopify.com"6 );78 await shopifyClient.graphql(9 `mutation ($input: ProductInput!) {10 productUpdate(input: $input) {11 product {12 id13 }14 userErrors {15 message16 }17 }18 }`,19 {20 input: {21 id: `gid://shopify/Product/${productId}`,22 ...productParams,23 },24 }25 );26 }27};
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.
1export const run: ActionRun = async ({ api, record, trigger, request, logger }) => {2 // will log the client making this API requests's IP3 logger.info(request.id);4 // will log the incoming Authorization request header5 logger.info(request.headers["authorization"]);6};
1export const run: ActionRun = async ({ api, record, trigger, request, logger }) => {2 // will log the client making this API requests's IP3 logger.info(request.id);4 // will log the incoming Authorization request header5 logger.info(request.headers["authorization"]);6};
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
(https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). signal.aborted
will become true when underlying request becomes closed or aborted.
Why/How?
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.
1export const run: ActionRun = async ({ api, record, request, signal }) => {2 for (const dog of await api.internal.dog.findMany()) {3 if (dog.age < 2) {4 await api.internal.dog.update({5 id: dog.id,6 description: `${dog.name} is a young dog`,7 });8 }910 // Use throwIfAborted() to rollback transactions that have not yet been committed11 signal.throwIfAborted();12 }1314 // This is a feature of fetch which allows you to pass a signal15 // If we timeout on the gadget server (the call took too long), fetch will immediately cancel16 const cats = fetch("someAPI/cats", { signal });1718 // it is okay if we don't create cats, so we return and have the action succeed19 if (signal.aborted) return;2021 await api.internal.cats.bulkCreate(cats);22};
1export const run: ActionRun = async ({ api, record, request, signal }) => {2 for (const dog of await api.internal.dog.findMany()) {3 if (dog.age < 2) {4 await api.internal.dog.update({5 id: dog.id,6 description: `${dog.name} is a young dog`,7 });8 }910 // Use throwIfAborted() to rollback transactions that have not yet been committed11 signal.throwIfAborted();12 }1314 // This is a feature of fetch which allows you to pass a signal15 // If we timeout on the gadget server (the call took too long), fetch will immediately cancel16 const cats = fetch("someAPI/cats", { signal });1718 // it is okay if we don't create cats, so we return and have the action succeed19 if (signal.aborted) return;2021 await api.internal.cats.bulkCreate(cats);22};
Action options
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 action is exposed in the GraphQL API.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // mark this action as a record creator5 actionType: "create",6};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // mark this action as a record creator5 actionType: "create",6};
The actionType
option can be one of the following:
create
: The action is exposed as acreate
mutation in the GraphQL API.create
s don't accept anid
parameter, but do accept parameters for each model's field. If any model fields have a Required validation, those fields must be passed to thecreate
mutation.create
s return the newly created record.update
: The action is exposed as anupdate
mutation in the GraphQL API.updates
s require being passed anid
parameter, and also accept optional parameters for each model's field.update
s return the updated record.delete
: The action is exposed as adelete
mutation in the GraphQL API.deletes
s require being passed anid
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.custom
s require being passed anid
parameter, and take no other params by default.custom
s 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.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // allow this action to take 1s before throwing a GGT_TIMEOUT_ERROR5 timeoutMS: 1000,6};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // allow this action to take 1s before throwing a GGT_TIMEOUT_ERROR5 timeoutMS: 1000,6};
transactional
Specifies if the action's run function should execute within a database transaction or not. Is true
by default.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // disable transactions for this action5 transactional: false,6};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // disable transactions for this action5 transactional: false,6};
returnType
Specifies whether the action should return the result of the run
function. Defaults to false
for model actions and true
for global actions.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // enable the ability to return a value from the `run` function of the action5 returnType: true,6};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 // enable the ability to return a value from the `run` function of the action5 returnType: true,6};
Let's take a look at a simple example of how to return a value from a run
function when setting returnType
to true
.
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, record, logger, api }) => {4 applyParams(params, record);5 await save(record);6 return {7 // returning an object8 result: "successfully returned",9 };10};1112export const onSuccess: ActionOnSuccess = async ({13 params,14 record,15 logger,16 api,17}) => {18 // cannot return from onSuccess!19};2021export const options: ActionOptions = {22 actionType: "custom",23 // Setting returnType to `true` as it is false by default on a model action24 returnType: true,25};
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, record, logger, api }) => {4 applyParams(params, record);5 await save(record);6 return {7 // returning an object8 result: "successfully returned",9 };10};1112export const onSuccess: ActionOnSuccess = async ({13 params,14 record,15 logger,16 api,17}) => {18 // cannot return from onSuccess!19};2021export const options: ActionOptions = {22 actionType: "custom",23 // Setting returnType to `true` as it is false by default on a model action24 returnType: true,25};
triggers
Specifies which triggers cause the action to execute.
API endpoint trigger
The API endpoint trigger is not shown within options but by default is true
in every model action.
The above applies to all model actions except default model actions on a Shopify-related model which do not contain an API endpoint trigger by default. However, custom model actions on a Shopify-related model will contain the API endpoint trigger
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "update",5 // API trigger will not be shown by default on newly created actions6 triggers: {},7};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "update",5 // API trigger will not be shown by default on newly created actions6 triggers: {},7};
If you choose to remove the trigger you'll need to explicitly set the API endpoint trigger to false
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "update",56 triggers: {7 // Pass in `api` and set it to false8 api: false,9 },10};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "update",56 triggers: {7 // Pass in `api` and set it to false8 api: false,9 },10};
Shopify webhook triggers
Shopify webhook triggers found in model actions are not shown within the options
by default.
However, if Shopify webhook triggers are added to global actions they are shown within the options
like the example below.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 triggers: {5 shopify: {6 webhooks: ["products/create", "products/delete", "products/update"],7 },8 },9};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 triggers: {5 shopify: {6 webhooks: ["products/create", "products/delete", "products/update"],7 },8 },9};
Adding and removing triggers
To add triggers you can just pass in the name of the associated trigger and set it to true
.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "create",5 returnType: true,6 triggers: {7 // adding in trigger8 emailSignUp: true,9 },10};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "create",5 returnType: true,6 triggers: {7 // adding in trigger8 emailSignUp: true,9 },10};
To remove any triggers, you can set the defined trigger in options to false
or remove the trigger name and value completely.
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "custom",5 returnType: true,6 triggers: {7 // setting trigger to `false` to remove it8 sendResetPassword: false,9 },10};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "custom",5 returnType: true,6 triggers: {7 // setting trigger to `false` to remove it8 sendResetPassword: false,9 },10};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "custom",5 returnType: true,6 // removing trigger7 triggers: {},8};
1import { ActionOptions } from "gadget-server";23export const options: ActionOptions = {4 actionType: "custom",5 returnType: true,6 // removing trigger7 triggers: {},8};
Adding parameters to actions
You can add new parameters to your model or global actions by exporting the params
object from your action file:
1export const params = {2 sendNotifications: { type: "boolean" },3};45export const run: ActionRun = async ({ params }) => {6 if (params.sendNotifications) {7 // ...8 }9};
1export const params = {2 sendNotifications: { type: "boolean" },3};45export const run: ActionRun = async ({ params }) => {6 if (params.sendNotifications) {7 // ...8 }9};
Gadget expects the exported params
object to be a subset of a JSON schema specification and currently supports the following JSON schema types:
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.
Adding params
to global actions
By default, global actions have no parameters. This means that in many cases, you need to define your own params
definition.
1// define params2export const params = {3 foo: { type: "string" },4 bar: { type: "number" },5};67export const run: ActionRun = async ({ params, logger, api, connections }) => {8 // params.foo and params.bar are available here9 logger.info({ foo: params.foo, bar: params.bar });10 // ... run the action11};
1// define params2export const params = {3 foo: { type: "string" },4 bar: { type: "number" },5};67export const run: ActionRun = async ({ params, logger, api, connections }) => {8 // params.foo and params.bar are available here9 logger.info({ foo: params.foo, bar: params.bar });10 // ... run the action11};
This global action could be called with:
await api.processWidgets({ foo: "hello", bar: 10 });
await api.processWidgets({ foo: "hello", bar: 10 });
Adding params
to model actions
Model actions already have a set of parameters automatically generated for them, depending on the type of model action and the fields on the model.
Adding additional params
is useful for actions that require parameters to change their behavior, but that you don't want to store on the model, and are useful for custom
model actions.
For example, a custom model action for a student
model may require a suspensionLength
parameter to suspend a student for a given number of days:
1import { applyParams, save, ActionOptions } from "gadget-server";2import { getUpdatedDate } from "../utils";34// define a new param5export const params = {6 suspensionLength: { type: "number" },7};89export const run: ActionRun = async ({10 params,11 record,12 logger,13 api,14 connections,15}) => {16 applyParams(params, record);1718 record.isSuspended = true;19 // schedule a background action to un-suspend the student20 await api.enqueue(21 api.update(22 record.id,23 { isSuspended: false },24 {25 startAt: getUpdatedDate(params.suspensionLength),26 }27 )28 );2930 await save(record);31};3233export const options: ActionOptions = {34 actionType: "custom",35};
1import { applyParams, save, ActionOptions } from "gadget-server";2import { getUpdatedDate } from "../utils";34// define a new param5export const params = {6 suspensionLength: { type: "number" },7};89export const run: ActionRun = async ({10 params,11 record,12 logger,13 api,14 connections,15}) => {16 applyParams(params, record);1718 record.isSuspended = true;19 // schedule a background action to un-suspend the student20 await api.enqueue(21 api.update(22 record.id,23 { isSuspended: false },24 {25 startAt: getUpdatedDate(params.suspensionLength),26 }27 )28 );2930 await save(record);31};3233export const options: ActionOptions = {34 actionType: "custom",35};
This action could be called with:
await api.student.suspend({ id: "123", suspensionLength: 3 });
await api.student.suspend({ id: "123", suspensionLength: 3 });
Additional params
can also be added to create, update, and delete model actions.
Adding object
parameters
Complex object
parameters can be added to actions to handle more advanced use cases.
The object
param type requires a properties
key with a mapping of the properties in the object to their types.
For example, if you want to expose a boolean flag and name object in your action or global action:
1// define a fullName object composed of first and last strings2export const params = {3 fullName: {4 type: "object",5 properties: {6 first: { type: "string" },7 last: { type: "string" },8 },9 },10};1112export const run: ActionRun = async ({ record, params, logger }) => {13 // params.fullName will be an object like { first: "Jane", last: "Dough" }14 logger.info({ firstName: params.fullName.first, lastName: params.fullName.last });15};
1// define a fullName object composed of first and last strings2export const params = {3 fullName: {4 type: "object",5 properties: {6 first: { type: "string" },7 last: { type: "string" },8 },9 },10};1112export const run: ActionRun = async ({ record, params, logger }) => {13 // params.fullName will be an object like { first: "Jane", last: "Dough" }14 logger.info({ firstName: params.fullName.first, lastName: params.fullName.last });15};
With that in place in your action code, you can now make the following API call:
await api.myAction({ fullName: { first: "Jane", last: "Dough" } });
await api.myAction({ fullName: { first: "Jane", last: "Dough" } });
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:
1export const params = {2 customerIds: { type: "array", items: { type: "string" } },3};45export const run: ActionRun = async ({ record, params }) => {6 // params.customerIds will be an array of strings7 for (const customerId of params.customerIds) {8 // ... do something with each customer id9 }10};
1export const params = {2 customerIds: { type: "array", items: { type: "string" } },3};45export const run: ActionRun = async ({ record, params }) => {6 // params.customerIds will be an array of strings7 for (const customerId of params.customerIds) {8 // ... do something with each customer id9 }10};
With that in place in your action code, you can now make the following API call:
await api.myAction({ customerIds: ["123", "456"] });
await api.myAction({ customerIds: ["123", "456"] });
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.
1await api.post.create({2 title: "My First Blog Post",3 author: {4 _link: "1",5 },6 body: "some interesting content",7 comments: [8 {9 create: {10 body: "first comment!",11 author: {12 _link: "2",13 },14 },15 },16 {17 create: {18 body: "another comment",19 author: {20 _link: "3",21 },22 },23 },24 ],25});
1mutation CreatePost($post: CreatePostInput) {2 createPost(post: $post) {3 success4 errors {5 message6 }7 post {8 id9 }10 }11}
1{2 "post": {3 "title": "My First Blog Post",4 "author": { "_link": "1" },5 "body": "some interesting content",6 "comments": [7 { "create": { "body": "first comment!", "author": { "_link": "2" } } },8 { "create": { "body": "another comment", "author": { "_link": "3" } } }9 ]10 }11}
1await api.post.create({2 title: "My First Blog Post",3 author: {4 _link: "1",5 },6 body: "some interesting content",7 comments: [8 {9 create: {10 body: "first comment!",11 author: {12 _link: "2",13 },14 },15 },16 {17 create: {18 body: "another comment",19 author: {20 _link: "3",21 },22 },23 },24 ],25});
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:
json1{2 "_converge": {3 "values": [4 // will update record with id 15 { "id": "1", "name": "first" },6 // will create a new record7 { "name": "second" }8 // other records not present in this list will be deleted9 ]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.
1await api.post.update("123", {2 title: "Updated Blog Post",3 body: "some interesting content",4 images: [5 {6 _converge: {7 values: [8 {9 caption: "Mountains",10 url: "https://example.com/mountains.jpg",11 },12 {13 id: "10",14 caption: "Oceans",15 url: "https://example.com/oceans.jpg",16 },17 ],18 },19 },20 ],21});
1mutation UpdatePost($id: GadgetID!, $post: CreatePostInput) {2 updatePost(id: $id, post: $post) {3 success4 errors {5 message6 }7 post {8 id9 }10 }11}
1{2 "post": {3 "title": "Updated Blog Post",4 "body": "some interesting content",5 "images": [6 {7 "_converge": {8 "values": [9 { "caption": "Mountains", "url": "https://example.com/mountains.jpg" },10 {11 "id": "10",12 "caption": "Oceans",13 "url": "https://example.com/oceans.jpg"14 }15 ]16 }17 }18 ]19 }20}
1await api.post.update("123", {2 title: "Updated Blog Post",3 body: "some interesting content",4 images: [5 {6 _converge: {7 values: [8 {9 caption: "Mountains",10 url: "https://example.com/mountains.jpg",11 },12 {13 id: "10",14 caption: "Oceans",15 url: "https://example.com/oceans.jpg",16 },17 ],18 },19 },20 ],21});
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:
json1{2 "_converge": {3 "values": [4 // ...5 ],6 "actions": {7 // will create any new records using the publicCreate action of the child model8 "create": "publicCreate",910 // will update any existing records using the specialUpdate action of the child model11 "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.