Created
May 14, 2025 13:24
-
-
Save airhorns/fcbb1b33fd6257b1c65ebc14ae29916c to your computer and use it in GitHub Desktop.
Gadget llms.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Overview | |
- Gadget is a full-stack application development and hosting platform. It provides developers with all of the tools needed to build, debug, deploy, host and scale apps, under one roof | |
- Gadget is used to build web applications in Javascript or Typescript. It is suitable for any project whose frontend or backend can be coded in Javascript or Typescript | |
- While Gadget apps can be full-stack, they don't necessarily need to be. Often the platform is used for projects without a frontend (e.g.integrating two APIs to automate data flow) | |
- The purpose of Gadget is to help developers deploy projects 100x faster (than with other tools) by building an end-to-end experience that rids the developer of undifferentiated work like infrastructure setup, glue code, CRUD APIs and all forms of other repetitive work | |
- Gadget accomplishes this purpose by offering an experience that couples the language, tooling, infrastructure, and boilerplate features and exposes them to the developer through a framework that saves time, and is highly extensible | |
- Gadget provides a cloud-based development environment that hosts and runs both the development version and production version of the app, with all requisite infrastructure like databases already set up | |
# Database | |
- Every Gadget environment gets persistent data storage in a shared Postgres that is secured, and scaled by Gadget. | |
- The database is administered through a UI inside of the Gadget IDE, which allows the user to create tables, add/edit fields and validations, and configure additional options. All schema migrations are handled by Gadget under the hood. | |
- When choosing to work locally using Gadget's CLI, the database can also be managed via a schema file that is exposed for each model in Gadget. | |
- There is no direct SQL access to the database for the developer. The database can be accessed in three ways: (1) via an auto-generated, permissioned, public, GraphQL API that respects the validations in place, (2) via an internal API with system admin privileges that can override validations, and (3) Gelly, a simple expression language (files end with `.gelly` are considered Gelly files), which allows direct data access without SQL for complex reads and aggregates. | |
- The Public API is to be used for all reads from the frontend, and any write from the frontend or backend that wants to run the entire action (including its business logic, and not just the data mutation). | |
- The internal API is to be used in two scenarios: (1) writes to the database from the backend that do not require the action's business logic to run and (2) data migrations and other data manipulation scenarios that require the user to be able to manipulate data in a table while ignoring all of the existing validations in place. The internal API is never to be used from the frontend. | |
- Gelly is used to access data for aggregates and computations that cannot be done via the API. Gelly can currently only be used inside of Gadget's computed fields offering, and within permission filters. | |
# Backend | |
- Gadget apps use a node.js backend with the Gadget JS framework | |
- The backend framework offers three core concepts: models, actions, and http routes (sometimes referred to as backend routes) | |
- Models are a Gadget framework concept that represents the storage layer (database) in the backend | |
- Each table in your database is represented by a model in the application's /api folder | |
- Each row in the table is represented by a model record | |
- Models are setup through the table editor interface inside the Gadget IDE | |
- Models have a defined schema composed of fields with different types, configs and validations | |
- Models in Gadget can be related to each other through a rich system of relationships; the following relationships are supported: | |
* `belongs to`: used for one-to-many relations, where a record references zero or one other parent record | |
* `has one`: used for one-to-one relations, where a record references one other child record | |
* `has many`: used for one-to-many relations, where a record references zero or more other child records | |
* `has many through`: used for many-to-many relations, where a record references zero or more other sibling records, and that the sibling records are related to the original record through an intermediate join model | |
- Each field represents a column in the application's database, with the exception of the 'file' and 'computed field' fields which are simplifications of other storage options offered by Gadget | |
- Actions are a Gadget framework concept, and represent the bulk of the server-side logic of the app | |
- Actions should only ever be used for **writes** to models or external systems, and never for just reading data | |
- Actions have many benefits over traditional backend routes: | |
(1) they offer built-in triggers like schedulers, GraphQL API endpoints, and webhooks from built-in Plugins | |
(2) they get typesafe, and have auto-generated GraphQL endpoints, JS clients and documentation | |
(3) they are instantly permissioned through the framework's role-based permissioning system | |
(4) they offer intuitive hooks that allow you to extend them to do any work, while ensuring that backend best practices are followed | |
- Actions come with built-in triggers that force an action to start to run when the trigger's conditions are met. Supported triggers include: | |
* the API trigger | |
* the scheduler trigger (for global actions only) | |
- Every action generated by Gadget comes with a bulk variant that allows it to be called with a list of records. Bulk actions are automatically added to the API when the action is created, and are marked with `bulkInvocation: true` in the action's metadata. | |
- The api identifier for the bulk action is the same as the action's api identifier but with the `bulk` prefix. For example, if the action's api identifier is `create`, the bulk action's api identifier is `bulkCreate`. | |
- The trigger will validate any input parameter types, and check that the call is initiated by a role that has permission to run the action. Once these conditions are met, the trigger will pass the necessary parameters to the action and the action will run | |
- Actions that interact with records on a model are sometimes referred to as model-scoped actions, whereas actions that don't directly interact with a record are sometimes called global-scoped actions. | |
- As soon as a new model is added to Gadget, the default create, update, delete model-scoped action files are generated, along with their API endpoints. By default, the files are coded to simply update the record's value. But | |
- Model-scoped actions have additional capabilities beyond basic CRUD : | |
* The GraphQL endpoint for reads and writes (create, update, and delete) are automatically permissioned via a role-based authentication system reading data from that table | |
* The input and output parameters for the actions are set to match the fields of the model by default, but can be changed in code. | |
* The writes come with an editable JS/TS file that runs when the API endpoint is triggered. This file has two built-in functions: | |
(1) `onRun`, is a function that takes the parameters sent into the GraphQL API endpoint and adds,edits, or deletes the record in context. You can customize the function by adding JS logic immediately before or after the database save, and choosing to save to the database transactionally, or not. | |
(2) `onSuccess`, executes if the `run` function ends successfully and is intended for business logic, like sending an email, or sms, or charging a card | |
- The framework allows for the creation of model-scoped actions with custom names other than create, update, and delete. While these custom model-scoped actions come with the same features as the default CRUD actions, they allow for better breaking down of the problem. Specifically, the framework directs developers to create custom named actions for distinct, high-frequency update operations on specific fields of a model, that are accompanied by business logic. Usually, these occur when the record's status fields change (an email sent to subscribers when a blog post's status goes from draft to published, whereas no email is sent when the blog post's title is updated). By mandating this convention, the framework prevents update actions from being overloaded with too much conditional business logic, keeps actions very declarative, and makes the application a lot more scalable | |
- Model-scoped actions always have a record in context, whereas global-scoped actions do not | |
- Model-scoped actions should always be used to house any logic related to interacting with records in its corresponding model. | |
- Global-scoped actions differ from model-scoped actions in that: | |
* They need their inputs and output parameters defined, and do not inherit them as default from a model's schema | |
* They do not have a record in context by default | |
* Their `run` function does not save edits made to records to the database, and because of this the `run` function is usually used for all business logic | |
* The `onSuccess` function is rarely used, other than to help breakdown work, as it has no capabilities that differ the run function | |
- Gadget also supports simple backend HTTP routes that aren't part of the auto generated API. Backend routes, can fully control the HTTP request and response to do things like HTTP streaming, render images, or other things that are impossible to use an action for. Http routes run on Fastify | |
- HTTP routes are ONLY to be used as an escape hatch out of Gadget's backend framework. They are not nearly as powerful, nor as easy to work with as actions, and should only be recommended when actions are not suitable | |
- HTTP routes can leverage Fastify's plugin ecosystem. To register fastify plugins or to otherwise modify the Fastify server instance, boot plugins are used. They must be registered before any other route plugins or the routes themselves. Boot plugins should live in the `api/boot` folder (otherwise they will not work as expected) | |
- You can place arbitrary files in the backend `api` folder and import them anywhere in the backend code, from within actions or HTTP routes. This is particularly useful for shared utilities, business logic, or API clients to external services | |
- Every Gadget app only serves requests over HTTPS; there is no need to modify any Gadget app to ensure it is HTTPS only. This is handled automatically by the framework | |
- Actions and HTTP routes both come pre-configured with a rich context that connects the action to Gadget's tooling (e.g. logger), allows access to the incoming trigger's contents, provides the ability to read from the database, as well as the ability to call the app's API server-side | |
- If a global-scoped action or http route wants to update a record in a model, it must first call a model-scoped action which will then actually edit the record. | |
- Http routes do not have the trigger in context, because they do not take triggers in the same sense that actions do | |
- All backend code has access to Gadget's backend tooling like the database and logger | |
# API | |
- Gadget autogenerates a GraphQL API for each data model and exposes a rich, type-safe TypeScript API client that can communicate with this API easily | |
- The API only allows reads and writes that have been explicitly granted to the role that is making the request | |
- The API is public and can be accessed by any client that has the URL, on the backend or frontend | |
- Every Gadget model has a Query field that can be used to read data from the model. The auto-generated client will have a collection of methods to interact with this field to read one or many records | |
- Every action has an associated GraphQL mutation that can be used to call the action. The auto-generated client will have a method to call this mutation | |
# Tenancy | |
- Gadget apps can be made multi-tenant, where tenants are isolated from one another and can only access their own data | |
- Tenancy is typically implemented through a tenant model (like `user`, `organization`, or `shopifyShop`) and `belongsTo` relationships from other models to this tenant model | |
- In Shopify apps, the `shopifyShop` model serves as the tenant model, and other models should have a `belongsTo` field named `shop` to establish tenancy | |
- For action code, Gadget provides utility functions to ensure that actions the actor invoking an action can act on the records in context | |
- Tenancy restrictions are enforced through the permission system using tenancy filters, which ensure users can only access records belonging to their tenant | |
- Tenancy can be enforced across relationships (e.g., if `comment` belongs to `post` and `post` belongs to `user`, tenancy can be enforced through `post.user`) | |
# Access control | |
- The Gadget framework always governs all data access control with a built in role-based access control system. Actors (users or API keys) in the system have roles, and actors can read model data or run actions if any of their roles have been granted permission | |
- All apps always have a `unauthenticated` role representing what actors without a session are entitled to, and a `system-admin` role that has every permission | |
- Row-level security is accomplished by extending the permissioning system by adding a Gelly filter to a specific permission to further refine it | |
- HTTP routes are not automatically secured by Gadget's framework and must be secured in code | |
# Authentication | |
Standalone apps, internal tools, or SaaS apps, use Gadget's built-in user authentication system, which supports email/password auth and Google SSO to secure an app. These apps will get a pre-configured `user` model for storing users, and each `session` model belongsTo a `user`. Session or user properties can then be used in access control checks to allow or disallow reads and writes to data. The `user` model has a `roleList` field that lists which roles each user has and thus what data it can read and actions it can run | |
# Session management | |
Sessions in Gadget are always managed using a server-side `session` model that every full-stack app has (backend, api only apps do not have a session model). The `session` model is just like any other model in that it has an API and can store arbitrary data, but it's backed by Redis for better performance. Gadget's generated Javascript, TypeScript and React clients track their current session ID, including across domains or from within embedded iframes. | |
# Frontend | |
- Every full-stack Gadget environment gets a fully setup, and hosted React frontend using Vite | |
- The frontend will often contain an unauthenticated area that is separated from an authenticated area via a login form, using Gadget's auth plugin | |
- Depending on the type of application being built, there may be a design system setup for the frontend already | |
- The frontend has the javascript/typescript API client automatically installed and configured, and always kept up to date with the latest version so there is no need to setup the API client, or update it when the backend is updated. Changes to the backend are exposed to the frontend instantly via this API. | |
- The frontend can also call the backend via the headless React hooks in the `@gadgetinc/react` library. | |
# Plugins | |
Plugins in Gadget are pre-built solutions or integrations to third APIs and services that make it easy for developers to add extensible blocks of common functionality. There are two types of plugins in Gadget: Connections, and Authentication methods. Connections are plugins that connect Gadget's backend to other services on the internet. Authentication methods offer extensible backend and frontend functionality for email/password login flows and Google SSO. | |
## Shopify connection | |
Gadget offers a managed data connection to Shopify that can be setup without code, and offers: (1) OAuth setup for single tenant apps installed on one store, or multitenant apps distributed on Shopify's app store, (2) instant scope and webhook registration and management, (3) webhook processing and routing to Gadget actions, (4) data syncing to replica database tables that are setup in Gadget's database instantly, (5) daily data reconciliation to see if any Shopify webhooks were missed, (6) an authenticated client that allows easy read and write access to Shopify's APIs, and (7) a fully setup admin UI embedded inside of Shopify's Admin for any stores that install the app. | |
## BigCommerce connection | |
Gadget offers a managed data connection to BigCommerce that can be setup without code, and offers: (1) OAuth setup for BigCommerce's "Single-Click" app type , (2) instant scope and webhook registration and management, (3) webhook processing and routing to Gadget actions, (4) an authenticated client that allows easy read and write access to Bigcommerce's APIs, and (5) a fully setup admin UI embedded inside of BigCommerce's control panel for any stores that install the app. | |
## OpenAI connection | |
Gadget offers a connection to OpenAI that provides an authenticated api client for calling OpenAI's API. | |
## Sentry connection | |
Gadget offers a connection to Sentry that reports runtime errors to Sentry. | |
## Email/Password Authentication Method | |
Gadget offers a built-in authentication method that allows developers to sign up, sign in, manage and reset passwords. This authentication method is tied to the presence of a user table in the developer's application. | |
## Google SSO Authentication Method | |
Gadget offers a built-in authentication method that allows users to sign up and sign in using their Google account. This authentication method is tied to the presence of a user table in the developer's application. | |
# Environment variables | |
Gadget allows users to set environment variables from the Gadget editor. These variables are available to the backend code in the `process.env` JavaScript object. Additionally, any environment variables that start with `GADGET_PUBLIC_` are exposed on the frontend/client side in the `process.env` global that is set using Vite's `define` macro. | |
## Project layout | |
Every Gadget app is stored as an on-disk folder of files of source code. Gadget stores an app's source code and allows developers to sync it to their local development environment if needed. Developers can source-control their apps using git, but Gadget has no source control system built-in. | |
By convention, all files in Gadget apps are named using camelCase. An app's filesystem might look like this: | |
api/ # stores all the backend code for the app | |
actions/ | |
someGlobalAction.ts # defines a global action named "someGlobalAction" | |
models/ | |
todo/ # defines a model named "todo" | |
actions/ | |
create.ts # defines the create action for the todo model | |
update.ts # defines the update action for the todo model | |
delete.ts # defines the delete action for the todo model | |
routes/ | |
GET-hello.ts # defines a backend HTTP route that can be accessed at /hello via a GET request | |
foo/ | |
POST-bar.ts # defines a backend HTTP route that can be accessed at /foo/bar via a POST request | |
boot/ | |
index.ts # a boot plugin that can set up project context, like API clients, plugins, etc | |
cors.ts # a boot plugin that registers @fastify/cors | |
utils/ | |
formatEmails.ts # a utility function for formatting emails | |
twilio.ts # a module that sets up a Twilio client | |
web/ # stores all the frontend code for the app | |
routes/ | |
index.tsx # an example root-level index route | |
about.tsx # the /about route | |
components/ | |
Header.tsx # a React component that renders the header for the app | |
main.tsx # the entry point for the frontend | |
package.json | |
vite.config.ts | |
index.html # the entry point for the frontend | |
The one exception to the camelCase naming rule is files who's names define URLs, which are backend and frontend routes. By convention, URLs in Gadget apps are dash separated, like /foo/bar/baz-qux, and not camel cased, as web URLs are not reliably case sensitive. So, files should be named like `api/routes/foo/bar/GET-baz-qux.ts` instead of `api/routes/foo/bar/GET-bazQux.ts` or similar on the backend. On the frontend, routes should be named with dashes as well, like `web/routes/foo.bar.baz-qux.tsx`. | |
Gadget apps don't define import aliases by default. When importing from files within the project, use relative imports. | |
## Naming data models | |
Data model identifiers and field identifiers must be both valid JavaScript and GraphQL identifiers, and are camelCase by convention. | |
## Fields | |
Every data model has a list of fields, similar to columns in a SQL table. Every data model will automatically have an `id` field storing an auto-incrementing primary key, and `createdAt` and `updatedAt` datetime fields storing the date and time the record was created and last updated. Fields are nullable by default but can be made mandatory with validations. | |
### Available field types | |
- `number`: stores a number in arbitrary precision | |
- `string`: stores a string of any length | |
- `boolean`: stores a boolean | |
- `dateTime`: stores a datetime or just a date | |
- `email`: stores an email address as a string | |
- `url`: stores a URL as a string | |
- `enum`: stores a short string selected from a predefined list of values | |
- `encryptedString`: stores a string that is encrypted at rest for added security | |
- `password`: stores a secure salted one-way hash of a string | |
- `json`: stores a valid JSON literal, including objects, arrays, and scalars | |
- `file`: stores a file in an automatically managed cloud storage bucket | |
- `vector`: stores a vector for vector similarity operations | |
- `richText`: stores markdown formatted text | |
- `roleList`: stores a set of access control role IDs that a record possesses for authorization checks | |
- `belongsTo`: stores a reference to a record of another data model by storing it's ID | |
- `hasOne`: defines a one-cardinality virtual reference to a record of another data model powered by a belongsTo field on the other model | |
- `hasMany`: defines a many-cardinality virtual reference to a set of records of another data model powered by a belongsTo field on the other model | |
- `hasManyThrough`: defines a many-cardinality virtual reference to a set of records of another data model powered by an intermediate join model to a third sibling data model, where the join model has belongsTo fields pointing at each outer model | |
- `computed`: computes an arbitrary value like a count or a sum using a simple expression language called Gelly | |
There are no other types beyond these. There's also no support for arrays of these types, though the enum type supports storing an array of specific strings, and the json type supports storing any JSON including arrays. There's no `id` type, as Gadget automatically creates a mandatory `id` field for every data model. To store a reference to a different record's `id`, use a relationship field type like `belongsTo`. | |
Gadget will autogenerate a feature-rich API for each data model that supports reading and writing and is able to do most of what SQL can do. The API always includes features for reading data out of the box with no need to manually add list actions or routes ever. For writes, the API is defined by the list of actions and global actions on the models. Calling Actions from the API will apply any listed validations and store data in the automatically managed database. The combination of data models and this automatic API will be used to build the application's logic and interface. | |
Gadget code is written in Javascript or TypeScript to run using real node.js v20 on the backend and modern browsers on the frontend. Gadget apps are like other nodejs apps in that they can use standard project layouts, modern JS features and use packages from npm. The Gadget framework takes care of booting the application and auto-generating a GraphQL API and TypeScript API client for that API for the application. Instead of implementing manual API endpoints, Gadget developers define actions in the Gadget framework, and Gadget takes care of exposing actions as mutations in the API, validating data on the way in and out, and invoking the action's code when API calls are made. | |
Backend code resides within the `api` directory and frontend code resides within the `web` directory with a shared `package.json` file at the root. Gadget uses file name conventions to define new actions, routes, and plugins, such that a file's path has meaning and implications about how it is treated. | |
Models are defined in the `api/models` directory, so model actions live in the `api/models/<some-model>/actions` directory, for example, `api/models/user/actions/create.ts`. | |
Global actions are defined in the `api/actions` directory, for example, `api/actions/sendWelcomeEmail.ts`. | |
Routes are defined in the `api/routes` directory, for example `api/routes/GET-hello.ts` or `api/routes/posts/[id]/POST-update.ts`. | |
Boot plugins are defined in the `api/boot` directory, for example `api/boot/twilio.ts`. | |
Other files can defined in any other subdirectory of the `api` directory, but are often conventionally put within the `api/lib` folder. | |
Gadget apps don't need to manually set up a GraphQL API or API client -- Gadget automatically generates a GraphQL API based on the actions and triggers, and exposes a pre-connected instance of the TypeScript API client for the app everywhere. Within a model action, global action, or route, the api instance should always be destructured from the passed context object. Because Gadget's API automatically adds functionality for reading data to the API, don't define any actions, global actions, or routes just for reading data. | |
```typescript | |
export const run: ActionRun = async ({ api }) => { | |
const widget = await api.widget.findFirst(); | |
}; | |
``` | |
Outside of these places you can also access an api client by importing it from the `gadget-server` package. | |
```typescript | |
import { api } from "gadget-server"; | |
await api.todo.create({name: "foo}); | |
``` | |
### Logging | |
Gadget offers developers a built in log viewer. Action and route contexts include a pino `logger` object that can be used to emit log entries viewable in Gadget's Log Viewer, and a `logger` object can be imported from the `gadget-server` package to be used in other parts of the app. On the backend, structured pino loggers should be used over console.log. | |
Gadget also already emits log entries at the start of requests and actions regardless of outcome. Unless you have important extra context to add, don't add log statements just to say that an action is starting or an action completed, as the framework is already logging that. | |
### API call basics | |
To read or write data to your app's database, use the Gadget app's generated API client. In both frontend and backend code, this API client allows reading data and running actions, similar to how you might use a database ORM in other frameworks. | |
#### Reading data | |
Each model of the application has reader functions exposed on the API client. For example, if I have a `todo` model, I can read todos like this: | |
```typescript | |
// get the first page of todos | |
let todos = await api.todo.findMany(); | |
// get one todo by id, throw if not found | |
const todo = await api.todo.findOne("123"); | |
// get 10 todos where the completed field is false, ordered by the createdAt field ascending | |
todos = await api.todo.findMany({ first: 10, filter: { completed: { equals: false } }, sort: { createdAt: "Ascending" } }); | |
`` | |
When calling reader functions or actions, you can control which fields are loaded by passing the `select` option. `select` accepts an object with keys for each field that should be loaded. Nested fields can be loaded using nested objects, nesting under the key of the relationship field you're selecting through. | |
Note that the type of the `id` field is `string`, so you should always use `string` literals for anything related to `id` fields. | |
```typescript | |
// get 10 todos, selecting only the id, title, completed fields from the todo model, and select the id field and title field of a related category model | |
todos = await api.todo.findMany({ | |
first: 10, | |
select: { | |
id: true, | |
title: true, | |
completed: true, | |
category: { id: true, title: true }, | |
}, | |
}); | |
``` | |
CRITICAL: the API client can ONLY return/fetch/query at most 250 records at a time. Setting the `first` or `last` option to a number greater than 250 will result in an error. | |
##### Filtering data | |
To filter data, pass a `filter` option to the reader function. The `filter` option accepts an object with keys for each field that should be used to filter the data. Each field's value should be an object with a key for the operator and a value for the operand. | |
Supported filter keys for different field types include: | |
- equals: Supported by string, number, boolean, dateTime, ID, enum, JSON, and vector fields. Filters records where the field is exactly equal to the given value. | |
- notEquals: Supported by string, number, boolean, dateTime, ID, enum, JSON, and vector fields. Filters records where the field is not equal to the given value. | |
- isSet: Supported by string, number, boolean, dateTime, ID, enum, JSON, and vector fields. Filters records where the field is set to any value other than `null`. | |
- in: Supported by string, number, dateTime, ID, enum, and JSON fields. Filters records where the field is exactly equal to one of the provided values. | |
- notIn: Supported by string, number, dateTime, ID, enum, and JSON fields. Filters records where the field is not equal to any of the provided values. | |
- lessThan: Supported by string, number, dateTime, and ID fields. Filters records where the field is less than the given value. | |
- lessThanOrEqual: Supported by string, number, dateTime, and ID fields. Filters records where the field is less than or equal to the given value. | |
- greaterThan: Supported by string, number, dateTime, and ID fields. Filters records where the field is greater than the given value. | |
- greaterThanOrEqual: Supported by string, number, dateTime, and ID fields. Filters records where the field is greater than or equal to the given value. | |
- startsWith: Supported by string fields. Filters records where the string field starts with the given string. | |
- contains: Supported by enum fields with `allowMultiple` configuration. Filters records where the field contains the given value. | |
- matches: Supported by JSON fields. Filters records where the JSON field contains the given key-value pair. | |
- before: Supported by dateTime fields. Filters records where the date-time field is before the given date-time. | |
- after: Supported by dateTime fields. Filters records where the date-time field is after the given date-time. | |
- cosineSimilarity: Supported by vector fields. Filters vector fields based on their cosine similarity to an input vector. | |
- l2Distance: Supported by vector fields. Filters vector fields based on their L2 (Euclidean) distance to an input vector. | |
Example usage: | |
```typescript | |
// get records with quantity greater than 10 | |
await api.widget.findMany({ | |
filter: { | |
quantity: { | |
greaterThan: 10, | |
}, | |
isPublished: { | |
equals: true, | |
}, | |
}, | |
}); | |
``` | |
```typescript | |
// get tickets that have a status of "backlog" or "in-progress" | |
await api.tickets.findMany({ | |
filter: { | |
status: { | |
in: ["backlog", "in-progress"], | |
}, | |
}, | |
}); | |
``` | |
```typescript | |
// get posts created after a specific date-time | |
await api.post.findMany({ | |
filter: { | |
createdAt: { | |
greaterThan: "2023-07-15T10:00:00Z", | |
}, | |
}, | |
}); | |
``` | |
You can also use the `AND` and `OR` operators to combine multiple filter conditions. | |
```typescript | |
// get orders that have a total price between 100 and 200 and a financial status of either "paid" or "refunded" | |
await api.shopifyOrder.findMany({ | |
filter: { | |
AND: [ | |
{ totalPrice: { greaterThan: 100 } }, | |
{ totalPrice: { lessThan: 200 } }, | |
{ | |
OR: [ | |
{ financialStatus: { equals: "paid" } }, | |
{ financialStatus: { equals: "refunded" } } | |
] | |
} | |
] | |
} | |
}); | |
``` | |
##### Sorting data | |
To sort data, pass a `sort` option to the reader function. The `sort` option accepts an object or array of objects that should have a field name to sort by as the key and the direction as the value. | |
``` | |
// sort by one field | |
await api.post.findMany({ | |
sort: { | |
publishedAt: "Descending" | |
} | |
}); | |
// sort by multiple fields | |
await api.post.findMany({ | |
sort: [ | |
{ | |
publishedAt: "Descending" | |
}, | |
{ | |
title: "Ascending" | |
} | |
] | |
}); | |
``` | |
### Available fields for sorting | |
Records can be sorted by most fields of their model in either the `Ascending` or `Descending` direction. The following field types are sortable: | |
- string, email, url: sorted using Postgres's alphanumeric sorting rules | |
- richText: sorted using Postgres's alphanumeric sorting rules on the `markdown` source text | |
- number: sorted by the number's value along the number line | |
- boolean: sorted with true higher than false. Descending puts true's first | |
- dateTime: sorted by the date and time's value, with earlier dates coming before later dates when using Ascending order | |
- id: sorted by the ID's numeric value, with lower IDs coming before higher IDs when using Ascending order | |
- enum: sorted by the enum values as strings, using Postgres's alphanumeric sorting rules | |
- json: sorted by the JSON string representation, using Postgres's alphanumeric sorting rules | |
- vector: sorted by a given vector distance operation | |
These field types aren't sortable: file, encryptedString, password, hasMany, hasOne, hasManyThrough, computed | |
#### Writing data | |
To write data to the app's database, call actions on the API client. | |
```typescript | |
// create a new todo record | |
const todo = await api.todo.create({title: "example todo", completed: false}); | |
// update a todo record by id | |
await api.todo.update("435", {title: "example todo", completed: false}); | |
``` | |
For `belongsTo` fields, you must set the ID of the related record using the `{ _link: "<id>" }` syntax. | |
```typescript | |
await api.todo.update("435", { | |
title: "example todo", | |
completed: false, | |
category: { | |
// category is a belongsTo field on the todo model pointing to a category model, and "123" is an ID of a category record | |
_link: "123" | |
} | |
}); | |
``` | |
##### Selecting which data to read | |
When calling reader functions or actions on models, you can control which fields are loaded in the result by passing the `select` option. `select` accepts an object with keys for each field that should be loaded. | |
You can select a limited set of fields by passing an object with keys for each field that should be loaded. | |
```typescript | |
// get the first page of todos, selecting only the title, completed fields from the todo model | |
const todos = await api.todo.findMany({ | |
first: 10, | |
select: { | |
id: true, | |
title: true, | |
completed: true, | |
} | |
}); | |
``` | |
You can also select data of related records by nesting under the key of the relationship field you're selecting through. For belongsTo and hasOne relationships, nest your selection directly under the name of the relationship field. For hasMany relationships, nest your selection under the name of the relationship field with `edges` and `node` keys to traverse the GraphQL Relay-style connection. Ensure you include the `id` field of a related record in your selection if you need it. | |
```typescript | |
// get the first page of todos, selecting only the title, completed fields from the todo model, and select the title field of a related category model | |
const posts = await api.posts.findMany({ | |
first: 10, | |
select: { | |
title: true, | |
body: true, | |
// single value relationship selects directly | |
author: { email: true } | |
// list relationship selects through edges and node | |
comments: { | |
edges: { | |
node: { | |
id: true, | |
body: true, | |
createdAt: true | |
} | |
}, | |
// gadget supports relay-style pagination, so select the page info if you need it as well | |
pageInfo: { | |
hasNextPage: true, | |
hasPreviousPage: true, | |
startCursor: true, | |
endCursor: true | |
} | |
} | |
} | |
}); | |
``` | |
Related data can then be accessed at properties with the same shape as the selection: | |
```typescript | |
// get the first post's author's email | |
const email = posts[0].author.email; | |
// get all the comment bodies for the first post | |
const commentBodies = posts[0].comments.edges.map((edge) => edge.node.body); | |
``` | |
##### Selecting from rich field types | |
When selecting from `richText` fields, you must use a subselection to select either the raw markdown source, or rendered HTML. The rendered HTML isn't styled but can be useful for display. | |
```typescript | |
const post = await api.posts.findOne("123", { | |
select: { | |
id: true, | |
someRichTextField: { | |
markdown: true, | |
truncatedHTML: true | |
} | |
} | |
}); | |
const markdown = post.someRichTextField.markdown; | |
const html = post.someRichTextField.truncatedHTML; | |
``` | |
##### Selecting from file fields | |
File fields are a special type of field that store files in the app's cloud storage bucket. File fields have multiple sub properties, and you must select the ones you need. | |
```typescript | |
const post = await api.posts.findOne("123", { | |
select: { | |
id: true, | |
someFileField: { | |
// a url to the file in cloud storage | |
url: true, | |
// the filename of the file | |
filename: true, | |
// the mime type of the file | |
mimeType: true, | |
// the size of the file in bytes | |
size: true | |
} | |
} | |
}); | |
``` | |
##### Selecting belongsTo field ids | |
Each `belongsTo` field allows you to retrieve the related record, or the id of the related record with a second, automatically available selection field suffixed by `id`. | |
```typescript | |
// select fields of the related record related record | |
const todo = await api.todo.findOne("123", { | |
select: { | |
id: true, | |
// select fields from the related record | |
category: { id: true, title: true } | |
} | |
}); | |
// or just select the id of the related record | |
const todo = await api.todo.findOne("123", { | |
select: { | |
id: true, | |
// select the id of the related record | |
categoryId: true | |
} | |
}); | |
``` | |
##### Selecting on action results | |
You can select fields from the result of an action by passing the `select` option to the action. | |
```typescript | |
// create a new todo record | |
const todo = await api.todo.create({title: "example todo", completed: false}, { select: { id: true, title: true } }); | |
// get the id and title of the todo | |
const id = todo.id; | |
const title = todo.title; | |
// completed won't be loaded on the returned todo because it wasn't selected | |
``` | |
##### Default selection of fields | |
If you don't pass a `select` option to a reader function, Gadget will select stored fields of the record by default. Computed fields will be excluded. Any fields of related records will be excluded. | |
If you do pass a `select` option, Gadget will select exactly the fields you pass, and exclude all other fields. This means that if you need fields like `id`, `createdAt`, or `updatedAt` in addition to your own fields, you must select them explicitly. | |
```typescript | |
const todos = await api.todo.findMany({ | |
first: 10, | |
select: { | |
id: true, | |
title: true, | |
completed: true, | |
// ensure you select the system fields you need in addition to others | |
createdAt: true, | |
} | |
}); | |
``` | |
#### Background actions | |
All actions that have an API trigger can also be enqueued for execution in a background job system. For example: | |
```typescript | |
// how you'd execute an action imperatively in the foreground | |
await api.widget.create({name: "foo"}); | |
// how you'd execute an action imperatively in the background | |
await api.enqueue(api.widget.create, {name: "foo"}); | |
``` | |
#### Sending emails | |
To send emails in backend code, use the `emails` object in the action or route context. | |
#### Scheduling a global action | |
To schedule a global action, add a `scheduler` field to the action options. | |
#### Calling the API from React components | |
Gadget offers React-specific hooks for reading data and calling actions with the API client within React components, like `useFindMany`, `useFindOne`, `useAction`, and `useGlobalAction`. These hooks manage the asynchronous nature of the API calls like intermediate loading states and errors for you. They use `urql` under the hood. | |
Model actions always operate on one record (new or existing) from the database table. Models always start with three actions: `create`, `update`, and `delete`. These actions are used to create, update, and delete records in the model. Developers can add more actions or remove these defaults. | |
Global actions do not operate on any particular model, and can be used to perform arbitrary logic. | |
Model actions and global actions are Javascript or TypeScript files that export: | |
- a `run` function, called by Gadget when an action is run | |
- an optional `onSuccess` function, called by Gadget when all the actions in an action group have `run` successfully and any database transactions have been committed | |
- an optional `options` object which configures things like the action's triggers, action type, timeout, etc | |
- an optional `params` object which defines extra inputs to the action | |
By default, model actions accept all the fields of the model as inputs, and return the record they operated on as output. By default, global actions accept no inputs and return the result of the `run` function. | |
### Action context | |
The `run` and `onSuccess` functions of model actions and global actions are passed one argument, `context`, which is always destructured to access the necessary elements. Action contexts have these keys: | |
- api: A connected, authorized instance of the generated API client for the current Gadget application. | |
- 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). Is an instance of the `GadgetRecord` class. | |
- session: A record representing the current session, if there is one. | |
- config: An object of all the environment variables registered in Gadget's Environment Variables web editor. | |
- connections: An object containing client objects for all connections. | |
- logger: A Pino 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. | |
- emails: An object that allows sending emails from Gadget. It is based on the `nodemailer` library. | |
For each action Gadget defines a module by module types `ActionRun` and `ActionOnSuccess` that are the types for the `run` and `onSuccess` functions. Important: the `ActionRun` and `ActionOnSuccess` types NEVER need to be imported, Gadget takes care of defining these types for you. | |
#### Action permissions | |
Permissions for who can run model actions and global actions are managed using Gadget's built in role-based access control system. To allow a role to run an action, you must grant that role the permission on the action using an appropriate tool. | |
Action permissions are NEVER specified in code within the action file or in the `options` object. | |
### Action triggers | |
Actions can be triggered by a variety of events. The events that trigger an action are defined in the action's `options` object. Available triggers: | |
- `api`: The most common trigger. Any action with this trigger is exposed as a mutation on the app's GraphQL API. | |
- `scheduler`: The action will be called on a user defined schedule. Only available on global actions. | |
You can change the triggers on an action by editing the action's js/ts file to modify the `triggers` key in the `options` object. Gadget has chosen good defaults for the triggers on actions that don't appear in the `options` object. Actions on user defined models have the `api` trigger by default. | |
### Action inputs and outputs | |
Action input params are determined by the action's type as well as optional custom params that are defined by the action's params object. | |
For model actions, by default: | |
- a `create` action accepts each field on the corresponding model as an optional input param | |
- an `update` action requires an `id` param and accepts each field on the corresponding model as an optional input param | |
- a `delete` action with `delete` type requires an `id` param | |
- a `custom` action with `custom` type requires an `id` param | |
All of the model action types above as well as global actions can configure additional input params by exporting a `params` object, which is defined using a subset of JSON Schema. If you require more information about the params object, search the Gadget documentation for custom action params. | |
By default, model actions return the record they operated on as output, however they can configure their options to return the result of the `run` function instead. By default, global actions return the result of the `run` function. | |
### Action Typescript types | |
Gadget generated `ActionRun` and `ActionOnSuccess` types for each individual action, and uses TypeScript module augmentation to expose them in each action file automatically. Never import these types, as they are automatically present in the file's context already. | |
The `ActionOptions` type defines the valid options for an action. If you're adding options, use this type and import it from the `gadget-server` package. | |
Don't define your own `ActionRun` or `ActionOnSuccess` types, or your own `ActionContext` type, as the framework already has high quality versions of these types that are correct. | |
### Modifying records in model actions | |
In model actions, the `record` object is a `GadgetRecord` instance you can modify to update the database. Modify properties directly on the `record` object, and then use the `save` function to update the database. | |
```typescript | |
// in api/models/todos/actions/create.ts | |
import { applyParams, save, ActionOptions } from "gadget-server"; | |
export const run: ActionRun = async ({ params, record, logger, api, connections }) => { | |
// applies any incoming params from the API call to the record | |
applyParams(params, record); | |
// modifies the record before saving | |
record.title = "new title"; | |
// saves the record to the database | |
await save(record); | |
}; | |
``` | |
The `save` function only updates the record itself, and does not save any related records, even if they are loaded on the `record` instance passed to `save`. If you need to update related records, you must call other top-level actions with the `api` object, or call `save` on instances of those related records. | |
### Accessing related data in model actions | |
The `record` object passed to model actions does *not* have any relationships loaded. You must use the passed `api` object to load related records if needed. | |
```typescript | |
// in api/models/todos/actions/update.ts | |
import { applyParams, save, ActionOptions } from "gadget-server"; | |
import { updateCategoryDetails } from "../../utils"; | |
export const run: ActionRun = async ({ params, record, logger, api, connections }) => { | |
applyParams(params, record); | |
// example: do something with the todo's category | |
const category = await api.category.findOne(record.categoryId); | |
await updateCategoryDetails(category); | |
// continue processing the todo record | |
await save(record); | |
}; | |
``` | |
If you want to modify other records in an action, you can call other top-level actions with the `api` object, or mutate records and save them with the `save` function if you don't want to run business logic on those other records. For this reason, don't set the value of hasMany, hasManyThrough, or hasOne fields on a `record` object, as this has no effect. | |
### Calling actions | |
An example model create action: | |
```typescript | |
// in api/models/todos/actions/create.ts | |
import { applyParams, save, ActionOptions } from "gadget-server"; | |
export const run: ActionRun = async ({ params, record, logger, api, connections }) => { | |
applyParams(params, record); | |
await save(record); | |
}; | |
export const options: ActionOptions = { | |
actionType: "create" | |
}; | |
``` | |
How you'd call this action given an instance of the API client as `api`: | |
```typescript | |
// imperatively | |
await api.todo.create({title: "example todo", completed: false}); | |
// enqueued in the background | |
await api.enqueue(api.todo.create, {title: "example todo", completed: false}); | |
// in a React component | |
const [{ data, error, isLoading }, run] = useAction(api.todo.create); | |
run({title: "example todo", completed: false}); | |
``` | |
An example model update action: | |
```typescript | |
// in api/models/todos/actions/update.ts | |
import { applyParams, save, ActionOptions } from "gadget-server"; | |
export const run: ActionRun = async ({ params, record, logger, api, connections }) => { | |
applyParams(params, record); | |
await save(record); | |
}; | |
export const options: ActionOptions = { | |
actionType: "update" | |
}; | |
``` | |
Example call: | |
```typescript | |
// imperative | |
await api.todo.update("123", {completed: true}); | |
// enqueued | |
await api.enqueue(api.todo.update, "123", {completed: true}); | |
// react | |
const [{ data, error, isLoading }, run] = useAction(api.todo.update); | |
run({id: "123", completed: true}); | |
``` | |
An example model delete action: | |
```typescript | |
// in api/models/todos/actions/delete.ts | |
import { deleteRecord, ActionOptions } from "gadget-server"; | |
export const run: ActionRun = async ({ params, record, logger, api, connections }) => { | |
await deleteRecord(record); | |
}; | |
export const options: ActionOptions = { | |
actionType: "delete" | |
}; | |
``` | |
Here's how you'd call this action: | |
```typescript | |
// imperative | |
await api.todo.delete("123"); | |
// background | |
await api.enqueue(api.todo.delete, "123"); | |
// react | |
const [{ data, error, isLoading }, run] = useAction(api.todo.delete); | |
run({id: "123"}); | |
``` | |
An example global action: | |
```typescript | |
// in api/actions/sendWelcomeEmail.ts | |
export const run: ActionRun = async ({ params, logger, api, connections }) => { | |
const todos = await api.todo.findMany(); | |
for (const todo of todos) { | |
await api.todo.delete(todo.id); | |
} | |
}; | |
``` | |
Example call: | |
```typescript | |
// imperative | |
await api.sendWelcomeEmail(); | |
// background | |
await api.enqueue(api.sendWelcomeEmail); | |
// react | |
const [{ data, error, isLoading }, run] = useGlobalAction(api.sendWelcomeEmail); | |
run(); | |
``` | |
Backend routes are HTTP handlers that Gadget will serve for an app. Backend routes are created by adding TypeScript files named with the `<method>-<path>` convention into the `api/routes` folder, for example `api/routes/GET-hello.ts`. | |
Example backend route filename => matched requests: | |
api/routes/GET.ts => GET / | |
api/routes/GET-foo.ts => GET /foo or GET /foo/, but not GET /bar or POST /foo | |
api/routes/GET-[id].ts => GET /foo, GET /bar, GET /1 but not POST /foo or GET /foo/bar | |
api/routes/blogs/GET-[id].ts => GET /blogs/1, GET /blogs/welcome-to-gadget | |
api/routes/blogs/POST.ts => POST /blogs, but not POST /blogs/1 | |
api/routes/category/[category]/blogs/GET-[id].ts => GET /category/cooking/blogs/1 or GET /category/baking/blogs/5 | |
api/routes/repos/GET-[...].ts => GET /repos/foo or GET /repos/foo/bar/baz | |
By convention, URLs in Gadget apps are dash separated, like /foo/bar/baz-qux, and not camel cased, as web URLs are not reliably case sensitive. So, files should be named like `api/routes/foo/bar/GET-baz-qux.ts` instead of `api/routes/foo/bar/GET-bazQux.ts` or similar. | |
IMPORTANT: Don't use frontend style file based routing syntax for backend routes. In backend routes, square brackets denote a parameter, but in frontend routes, $ signs do. | |
Backend routes should have one default export of a single async function that accepts a request context object. By convention, keys from this context are destructured in the signature for use in the function body. The request context includes: | |
- request - a FastifyRequest object describing the incoming HTTP request | |
- reply - a FastifyReply object for sending an HTTP response | |
- api - a connected, authorized instance of the generated API client for the current Gadget application. | |
- applicationSession - a record representing the current user's session, if there is one. | |
- applicationSessionID - the ID of the record representing the current user's session, if there is one. | |
- connections - an object containing client objects for all Gadget-managed data connections this app has set up | |
- logger - a Pino logger instance for structured logging (should be preferred over console.log) | |
- config - an object of all the environment variables registered in Gadget's Environment Variables editor. | |
- currentAppUrl - the current url for the environment. e.g. https://my-app.gadget.app | |
Backend route functions execute like a normal Fastify route handler. They don't need to return anything, and instead they need to await sending a reply to the client with the `reply` object. Unlike fastify or express, Gadget uses the full variable names `request` and `reply` for the request and reply objects instead of the shorthand `req` and `res`. Route options can be passed to fastify by setting the `options` property on the route function. | |
Here's an example backend route file: | |
```typescript | |
// in api/routes/quotes/GET-[id].ts | |
import { RouteHandler } from "gadget-server"; | |
const route: RouteHandler = async ({ request, reply }) => { | |
if (request.params.id == "1") { | |
await reply.send("To be or not to be"); | |
} else { | |
await reply.code(404).send("Unknown quote id: " + request.params.id); | |
} | |
} | |
route.options = { | |
// example route options | |
logLevel: "warn", | |
}; | |
export default route; | |
``` | |
Backend routes should not be confused with frontend routes. Backend routes execute server-side only using Fastify, and frontend routes execute either server-side or client-side with using a framework like Remix or React Router. Frontend routes should be used for user-facing interface, like a sign-in page or home screen, and backend routes should be use for anything systems-facing or anything requiring server-side execution, like webhook handlers, health-monitoring endpoints, or image generation. | |
If this application is using TypeScript, be sure to import the `RouteHandler` type in the file, type the route function as a `RouteHandler`, and export the route as the default export. The `RouteHandler` can also be used for type safe handling of route parameters, request body, and request query parameters, in the same way that the `RouteGenericInterface` from `fastify` can be used. | |
Route params can be typed using the `Params` element of the `RouteHandler` type. If you know the route accepts an `id` parameter in the path, you can type the route function as follows: | |
```typescript | |
import { RouteHandler } from "gadget-server"; | |
const route: RouteHandler<{ Params: { id: string } }> = async ({ request, reply }) => { | |
const id = request.params.id; | |
} | |
export default route; | |
``` | |
Route bodies can be typed using the `Body` element of the `RouteHandler` type. If you know the route accepts a `name` field in the body, you can type the route function and validate the body by passing a JSONSchema object to the `schema` option on the route function: | |
```typescript | |
// in api/routes/quotes/POST.ts | |
import { RouteHandler } from "gadget-server"; | |
const route: RouteHandler<{ Body: { name: string } }> = async ({ request, reply }) => { | |
const name = request.body.name; | |
} | |
// export the expected route schema within the route options | |
route.options = { | |
schema: { | |
body: { | |
type: "object", | |
properties: { | |
name: { type: "string" }, | |
}, | |
required: ["name"], | |
}, | |
}, | |
}; | |
export default route; | |
``` | |
The framework will validate the request body against the schema you provide, and will return a 400 error if the body is invalid, so don't add your own validation against the body schema. If extra validation that JSONSchema can't express is needed, you can add it in the route handler. | |
Be sure that the types you use for route parameters, request body, and request query parameters match the runtime options you set on the `route` object itself. | |
Gadget apps can host frontends built using React and Vite. This frontend part of the application lives in the `web` directory, and shares the root `package.json` file with the backend. The root level `vite.config.mjs` file is always already set up to serve the app in the `web` folder. | |
React components that are re-used in multiple frontend routes should be placed within `web/components` or a subdirectory unless explicitly instructed otherwise. New components you create should use named exports, and be imported using named imports unless there's a strong reason not to. | |
### React hooks from `@gadgetinc/react` | |
Gadget provides a wide set of React hooks for building frontend applications with react from within the `@gadgetinc/react` package, which comes pre-installed in every Gadget app. The `@gadgetinc/react` package includes the hooks listed in the <gadget_inc_react_hooks> tag. | |
#### Hooks in `@gadgetinc/react` | |
- `useFindOne(manager, id, options?): [{data, fetching, error}, refetch]` - fetches a single record by ID, throwing if not found. | |
- `useMaybeFindOne(manager, id, options?): [{data, fetching, error}, refetch]` - fetches a single record by ID, returning null if not found. | |
- `useFindMany(manager, options?): [{data, fetching, error}, refetch]` - fetches multiple records with filters and pagination. | |
- `useFindFirst(manager, options?): [{data, fetching, error}, refetch]` - fetches the first record matching the criteria. | |
- `useFindBy(findFunction, fieldValue, options?): [{data, fetching, error}, refetch]` - fetches a record by a unique field value. | |
- `useAction(action, params, options?): [{data, fetching, error}, run]` - runs one model action against a record of that model | |
- `useGlobalAction(action, params, options?): [{data, fetching, error}, run]` - runs one global action | |
- `useGet(singletonModelManager, options?): [{data, fetching, error}, refetch]` - retrieves the current singleton record for a singleton model | |
- `useEnqueue(action, params, options?): [{data, fetching, error}, run]` - enqueues a model action or global action to be run in the background | |
- `useActionForm(action, params, options?): [{data, fetching, error}, run]` - headlessly manages state for a form that calls a model action or global action using react-hook-form | |
- `useList(model, options?): [{data, fetching, page, search, error}, refresh]` - headlessly manages fetching records and pagination state for a list of records | |
- `useTable(model, options?): [{data, fetching, page, search, sort, selection, error}, refresh]` - headlessly manages fetching records and pagination state for a sortable table of records | |
- `useFetch(path: string, options: RequestInit = {}): [{data, fetching, error}, refetch]` - fetches remote content over HTTP using Gadget's built in authentication mechanisms, like a backend route or a 3rd party API | |
- `useSession(): Session` - retrieves the currently authenticated session record from the backend | |
- `useUser(): User | null` - retrieves the currently authenticated user record from the backend | |
IMPORTANT: `useGadget` is not available in the `@gadgetinc/react` library. | |
### Frontend data fetching | |
Frontend routes that need dynamic data should use hooks from the `@gadgetinc/react` package to fetch or mutate data from the backend. | |
The hooks from `@gadgetinc/react` automatically enforce permissions, correctly manage re-fetching data as other data is changed, and are type-safe. | |
For example, in a todo application, we can render a list of todos powered by the `useFindMany` hook `web/routes/_app.todos.tsx`: | |
```typescript | |
import { useFindMany } from "@gadgetinc/react"; | |
import { api } from "../api"; | |
export default function () { | |
const [{data, fetching, error}] = useFindMany(api.todo); | |
if (fetching) { | |
return "Loading"; | |
} | |
if (error) { | |
return "Error: " + error; | |
} | |
return <ul> | |
{data.map(todo => <li key={todo.id}>{todo.title}</li>)} | |
</ul>; | |
} | |
```; | |
You can use the `select` option to select specific fields of the records you need, or fields of related records. | |
```typescript | |
// select the first page of todos, including the todo's id and title, and the todo's related category record's id and title | |
const [{data, fetching, error}] = useFindMany(api.todo, { | |
select: { | |
id: true, | |
title: true, | |
category: { | |
id: true, | |
title: true, | |
}, | |
}, | |
}); | |
```; | |
When invoking hooks like this, prefer invoking one big hook that selects all the data you need to invoking the hooks multiple times with different models to then associate the data client side. If you need different filters, sorts, or views of the same model though, you can invoke multiple hooks. | |
### Frontend mutations | |
Frontend routes that need to run mutations should use Gadget's `useAction` or `useGlobalAction` React hooks (and not use Remix actions). Unlike Remix actions, these hooks automatically enforce permissions and are type-safe. | |
For example, in a todo application, we can create a todo item with a `useAction` hook in `web/routes/_app.create-todo.tsx`: | |
```typescript | |
import { useState } from "react"; | |
import { useAction } from "@gadgetinc/react"; | |
import { api } from "../api"; | |
export default function () { | |
const [{data, fetching, error}, create] = useAction(api.todo.create); | |
const [title, setTitle] = useState(""); | |
return <form onSubmit={(event) => { | |
event.preventDefault(); | |
void create({title: title}); | |
}}> | |
{fetching && "Saving"} | |
{error && `There was an error! ${error}`} | |
<label for="title">Title:</label> | |
<input name="title" value={title} onChange={(event) => setTitle(event.currentTarget.value)}/> | |
</form> | |
} | |
```; | |
#### Updates and Custom Actions | |
You can use the `useAction` hook to run update actions or custom actions on specific records. Note that the `useAction` hook takes all the parameters for an action in object form, so you need to pass the record's id as a key in this object, not as the sole parameter. | |
For example, in a todo application, we can update a todo item with a `useAction` hook: | |
```typescript | |
import { useAction } from "@gadgetinc/react"; | |
import { api } from "../api"; | |
export default function () { | |
const [{data, fetching, error}, update] = useAction(api.todo.update); | |
// in some callback | |
const handleSubmit = (event) => { | |
void update({id: 1, title: title}); | |
} | |
// ui omitted for brevity | |
} | |
```; | |
#### Deletes | |
You can use the `useAction` hook to run the delete action on specific records. Note that the `useAction` hook takes all the parameters for an action in object form, so you need to pass the record's id as a key in this object, not as the sole parameter. | |
For example, in a todo application, we can delete a todo item with a `useAction` hook: | |
```typescript | |
import { useAction } from "@gadgetinc/react"; | |
import { api } from "../api"; | |
export default function () { | |
const [{data, fetching, error}, runDelete] = useAction(api.todo.delete); | |
// in some callback | |
const handleSubmit = (event) => { | |
void runDelete({id: 1}); | |
} | |
// ui omitted for brevity | |
} | |
```; | |
### Accessing an instance of the api client | |
Frontend Gadget applications almost always have a module that exports an instance of the API client object for use in frontend components at `web/api.ts`. This file is automatically added by Gadget and exports the instance under the `api` variable. | |
When making API calls with React hooks, or passing the api object's functions to hooks and components, ensure you import the `api` object from the `web/api.ts` file using a correct *relative* import. | |
For a route at `web/routes/foo.tsx`, the `api` object should be imported from `../api.ts`: | |
```typescript | |
import { api } from "../api"; | |
export default function () { | |
api.todo; // the todo model manager object is accessible here | |
return <div>Hello world</div>; | |
} | |
``` | |
For a component at `web/components/some-section/some-page/Header.tsx`, the `api` object should be imported from `../../../api.ts`, etc. | |
Use of the `useApi` hook from `@gadgetinc/react` is strongly discouraged because it returns a generic type instead of the app's specific API client's type, and can thus conceal very bad errors. | |
Gelly is a special expression language developed by Gadget for controlling permissions and computing field values using 'computed fields'. Files ending in `.gelly` are Gelly files. | |
Gelly supports literals for a variety of built-in data types that you can use in your expressions. | |
Below are some basic examples of Gelly code in different use cases: | |
<access control> | |
Use case: create a filter that only lets users see records from the "Post" model that have the published field set to a truthy value. | |
```gelly | |
filter($session: Session) on Post [ | |
where published | |
] | |
``` | |
Use case: create a filter that only lets users see their own posts. | |
```gelly | |
filter ($session: Session) on Post [ | |
where userId == $session.userId | |
] | |
``` | |
</access control> | |
<computed fields> | |
Use case: create a computed field to the "customer" model that combines the "firstName" field and "lastName" field of a user to create a full name. | |
```gelly | |
field on customer { | |
concat([firstName, " ", lastName]) | |
} | |
``` | |
Use case: the "user" model has a field called "chatMessages" that is a hasMany relationship to the "chatMessage" model. Create a computed field to the "user" model that sums the "tokenCount" field from of all "chatMessages" records created in the past 30 days. | |
```gelly | |
field on user { | |
sum(chatMessages.tokenCount, where: chatMessages.createdAt > now() - interval("30 days")) | |
} | |
``` | |
Use case: create a computed field to round a number with 2 decimal places. | |
```gelly | |
field on user { | |
round(count, precision: 2) | |
} | |
``` | |
</computed fields> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment