example-app Internal vs public API endpoints
Gadget exposes a second, lower-level set of queries and mutations in the GraphQL API called the internal API. The internal API lets trusted parties make low-level changes that the public API doesn't allow for maximum flexibility.
When to use the public API
The public API facilitates normal, expected requests, and should be used for all of the experiences powered by Gadget. It's fast, it's powerful, and it lets developers make assumptions about the data moving in and out of the system with validations. The public API should be the default tool developers reach for.
When to use the internal API
The internal API should only be used when you need to perform tasks that deviate from normal interactions provided by the public API, such as debugging, troubleshooting, or accessing hidden data. However, use it sparingly and with caution, as it provides direct, low-level access that can potentially disrupt data integrity and violate system guarantees.
You can think of it as the SQL layer of the Gadget ecosystem as it gives raw, unfettered access to the low-level storage systems. It is the tool that the Public API uses to make changes under the hood, and for developers, it is also the thing to use when you need to make surgical or otherwise invalid changes under the hood.
It's generally advisable to avoid using the internal API until you have no other choice because of its low-level nature. The internal API is a sharp tool that can easily break data and violate important promises made by other parts of example-app.
Calling actions within the internal API
Sometimes 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 action framework.
No validations
Most validations save for critical invariants are not run for internal API requests. If you would like to ensure data added is valid, call an action using the public API. The internal API is suitable for fixing invalid data or changing things to pass a validation that may be added in the future.
Support for atomic operations
The public API for example-app works by reading data from the database, executing JS to update the record in memory, and then writing the updated record back to the database. Under high concurrency, this can create race conditions, where requests processing in parallel accidentally overwrite each other's changes by both reading the same data, updating to different values in memory, and then both writing back, where the last write stomps on the first.
For these situations, the internal API supports atomic operations, which apply changes in serial inside the database. These changes are each guaranteed to be applied in order if their wrapping transaction commits, ensuring that no stomping happens at all.
Atomic operations are triggered by passing inputs to the special _atomics
input field in an internal API create
or update
call.
No live queries
The internal API does not support the live: true
query option for subscribing to changes in the backend. If you need realtime queries, use the public API.
No nested writes
The internal API does not support creating, updating, or deleting related records. If you need to perform these actions on related records, use the public API. You can find information on how to do this in your models' documentation.
Crafting internal API requests
The internal API is accessed via the same GraphQL system that the public API is. Internal API requests go through the same GraphQL API endpoint that the public API, and it uses the same permissions system and serialization formats. The Internal API queries and mutations are nested under an internal
field within the main GraphQL schema. So, any GraphQL client can be used to make internal API calls. The example-app JavaScript client supports making internal API calls as well which is documented below.
Authorization
Unlike the public API, access to the internal API is granted as one top-level permission in the Gadget editor. The Internal API Access
permission must be granted to a role the actor making the API call has for the call to succeed.
By default, only the Sysadmin
role has permission to execute internal API requests. To grant an API key permission to make internal API requests, you could add the Sysadmin
role to that API key, or create a new role with the Internal API Access
permission granted, and add that role to a trusted API key.
Making internal API calls from JavaScript
The generated example-app JavaScript client has support for making internal API calls built-in. To install and configure your JavaScript client, see the Installing section.
Once installed, internal API requests are made using the client.internal.<model name>.<action name>
functions.
Like the public API, the internal API uses singular model API identifiers in both GraphQL mutations and the names of the JavaScript
objects. If you access a model in the Public API as api.widget.findMany
, the internal API would be accessed as
api.internal.widget.findMany
.
Reading raw data
The internal API supports reading data for raw, low-level access to what's stored in the Gadget database. All the models from the public API are available in the internal API, and field values are the same as what's returned in the public API. But, the internal API offers access to fields disabled in the public API, and may differ when data migrations are underway or have failed with errors.
Read one raw record by ID
Individual records can be fetched via the internal API using the findOne
JS function or the internal.<modelName>
GraphQL field.
The findOne
function throws an error if no matching record is found, which you will need to catch and handle. Alternatively, you can use
the maybeFindOne
function, which returns null
if no record is found, without throwing an error.
const userRecord = await api.internal.user.findOne("123");// => a stringconsole.log(userRecord.id);// => a Date objectconsole.log(userRecord.createdAt);
1query InternalFindUser($id: GadgetID!, $select: [String!]) {2 internal {3 user(id: $id, select: $select)4 }5 gadgetMeta {6 hydrations(modelName: "user")7 }8}
{ "id": "123" }
const userRecord = await api.internal.user.findOne("123");// => a stringconsole.log(userRecord.id);// => a Date objectconsole.log(userRecord.createdAt);
Read many raw records
Lists of records can be fetched via the internal API using the findMany
JS function or the internal.list<modelName>
GraphQL field. The internal API supports the same options that the public API does for sorting, filtering, and paginating the list of returned records. Consult the public API documentation for each model for more details on these parameters.
const userRecords = await api.internal.user.findMany({ sort: {}, filter: {} });// => a stringconsole.log(userRecords[0].id);// => a string containing an ISO 8601 encoded Dateconsole.log(userRecords[0].createdAt);
1query InternalFindManyUser($sort: [UserSort!], $filter: [UserFilter!]) {2 internal {3 listUser(sort: $sort, filter: $filter) {4 pageInfo {5 hasNextPage6 hasPreviousPage7 startCursor8 endCursor9 }10 edges {11 cursor12 node13 }14 }15 }16 gadgetMeta {17 hydrations(modelName: "user")18 }19}
{ "sort": {}, "filter": {} }
const userRecords = await api.internal.user.findMany({ sort: {}, filter: {} });// => a stringconsole.log(userRecords[0].id);// => a string containing an ISO 8601 encoded Dateconsole.log(userRecords[0].createdAt);
Read the first of many raw records
The first record from a list of records can be fetched via the internal API using the findFirst
JS function. The internal API supports the same options that the public API does for sorting and filtering, though no pagination options are available on this function. Consult the public API documentation for each model for more details on these parameters.
The findFirst
function throws an error if no matching record is found, which you will need to catch and handle. Alternatively, you can
use the maybeFindFirst
function, which returns null
if no record is found, without throwing an error.
const userRecord = await api.internal.user.findFirst({ sort: {}, filter: {} });// => a stringconsole.log(userRecord.id);// => a Date objectconsole.log(userRecord.createdAt);
1query InternalFindFirstUser(2 $sort: [UserSort!]3 $filter: [UserFilter!]4 $first: Int5) {6 internal {7 listUser(sort: $sort, filter: $filter, first: $first) {8 edges {9 node10 }11 }12 }13 gadgetMeta {14 hydrations(modelName: "user")15 }16}
{ "first": 1, "sort": {}, "filter": {} }
const userRecord = await api.internal.user.findFirst({ sort: {}, filter: {} });// => a stringconsole.log(userRecord.id);// => a Date objectconsole.log(userRecord.createdAt);
The internal select
param
Like the public API, the internal API supports only returning a subset of fields in the response. Pass a list of fields in the select
param to the options of your findOne
or findMany
calls to return only certain fields:
// only return the id and createdAt field on the post recordconst post = await api.internal.user.findFirst({select: { id: true, createdAt: true },});
query {internal {user(select: { id: true, createdAt: true })}}
// only return the id and createdAt field on the post recordconst post = await api.internal.user.findFirst({select: { id: true, createdAt: true },});
The internal API select
param does not support selecting fields of related models. You can only select fields on the model you are
querying.
To select fields of related models at the same time as fields from the model you are querying, use the public API instead.
Note that selection within GraphQL queries for the internal API does not use GraphQL fields like the public API does. Instead, pass the list of fields you wish to select as a list of strings to the select
field argument. This allows the internal API to continue to return schemaless data.
Writing raw data
The internal API supports low level data writes for making raw changes to the database. Internal API create
s, update
s, and delete
s are often used for actually implementing the run
functions that power the public API actions. These mutations also act as a developer's escape hatch for modifying data outside the realm of an action, where they may need to migrate data, import from another source, or correct a bug.
Internal API writes are conduced using the api.internal.<modelName>.<verb>
series of functions. These functions take just the record id (if needed) and the record data (if needed) as arguments, and can't be changed or disabled.
Internal API create
calls and update
calls support the _atomics
input object, allowing for concurrency-safe increments and decrements to numeric fields.
Create a record
Records can be created using the internal API.
1const userRecord = await api.internal.user.create({2 // ... values3});4// => a string5console.log(userRecord.id);6// => a string containing an ISO 8601 encoded Date7console.log(userRecord.createdAt);
1mutation InternalCreateUser($user: InternalUserInput) {2 internal {3 createUser(user: $user) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 user19 }20 }21 gadgetMeta {22 hydrations(modelName: "user")23 }24}
{ "user": {} }
1const userRecord = await api.internal.user.create({2 // ... values3});4// => a string5console.log(userRecord.id);6// => a string containing an ISO 8601 encoded Date7console.log(userRecord.createdAt);
Create many records
Records can be bulk created through one call of the internal API through the bulkCreate
method. Use the api.internal.user.bulkCreate
JS client function or the internal.bulkCreate
GraphQL field to create many records.
await api.internal.user.bulkCreate([{ id: 123 }, { id: 321 }]);
1mutation InternalBulkCreateUsers($users: [InternalUserInput]!) {2 internal {3 bulkCreateUsers(users: $users) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 users19 }20 }21 gadgetMeta {22 hydrations(modelName: "user")23 }24}
[{ "id": 123 }, { "id": 321 }]
await api.internal.user.bulkCreate([{ id: 123 }, { id: 321 }]);
Update a record
Records can be updated by ID using the internal API.
1const userRecord = await api.internal.user.update(10, {2 // ... values3});4// => a string5console.log(userRecord.id);6// => a string containing an ISO 8601 encoded Date7console.log(userRecord.createdAt);8// => now changed, a string containing an ISO 8601 encoded Date9console.log(userRecord.updated);
1mutation InternalUpdateUser($id: GadgetID!, $user: InternalUserInput) {2 internal {3 updateUser(id: $id, user: $user) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 user19 }20 }21 gadgetMeta {22 hydrations(modelName: "user")23 }24}
{ "id": 10, "user": {} }
1const userRecord = await api.internal.user.update(10, {2 // ... values3});4// => a string5console.log(userRecord.id);6// => a string containing an ISO 8601 encoded Date7console.log(userRecord.createdAt);8// => now changed, a string containing an ISO 8601 encoded Date9console.log(userRecord.updated);
Upsert a record
Records can be upserted by ID. If a record with the specified ID does not exist it will be created, if it does exist it will be updated.
await api.internal.user.upsert({id: "123",// ... values});
1mutation InternalUpsertUser($on: [String!], $user: InternalUserInput) {2 internal {3 upsertUser(on: $on, user: $user) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 user19 }20 }21 gadgetMeta {22 hydrations(modelName: "user")23 }24}
{ "user": { "id": "123" } }
await api.internal.user.upsert({id: "123",// ... values});
If your model has a field with a uniqueness validation you can also use that field (or fields) to upsert. For example if the user
model has a field uniqueField
:
1await api.internal.user.upsert({2 user: {3 uniqueField: "uniqueValue",4 // ... values5 },6 on: ["uniqueField"],7});
1mutation InternalUpsertUser($on: [String!], $user: InternalUserInput) {2 internal {3 upsertUser(on: $on, user: $user) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 user19 }20 }21 gadgetMeta {22 hydrations(modelName: "user")23 }24}
{ "user": { "uniqueField": "uniqueValue" }, "on": ["uniqueField"] }
1await api.internal.user.upsert({2 user: {3 uniqueField: "uniqueValue",4 // ... values5 },6 on: ["uniqueField"],7});
Delete a record
Records can be deleted by ID using the internal API.
await api.internal.user.delete(10);
1mutation InternalDeleteUser($id: GadgetID!) {2 internal {3 deleteUser(id: $id) {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 }19 }20}
{ "id": 10 }
await api.internal.user.delete(10);
Delete many records
Records can be deleted by a set of filters using the internal API. Records are deleted performantly in batches under the hood, and like the rest of the internal API, no actions are run. Use the api.internal.user.deleteMany
JS client function or the internal.deleteManyUser
GraphQL field to delete records.
await api.internal.user.deleteMany();
1mutation InternalDeleteManyUser {2 internal {3 deleteManyUser {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 }19 }20}
{}
await api.internal.user.deleteMany();
await api.internal.user.deleteMany({ filter: { id: { greaterThan: 10 } } });
1mutation InternalDeleteManyUser {2 internal {3 deleteManyUser {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 }19 }20}
{ "filter": { "id": { "greaterThan": 10 } } }
await api.internal.user.deleteMany({ filter: { id: { greaterThan: 10 } } });
await api.internal.user.deleteMany({ search: "some search" });
1mutation InternalDeleteManyUser {2 internal {3 deleteManyUser {4 success5 errors {6 message7 code8 ... on InvalidRecordError {9 model {10 apiIdentifier11 }12 validationErrors {13 message14 apiIdentifier15 }16 }17 }18 }19 }20}
{ "search": "some search" }
await api.internal.user.deleteMany({ search: "some search" });
Atomically increment a record field
Records can be updated using the atomic increment operators for consistent, in-database updates to numeric fields. Atomic increments are very useful when trying to build counters that are potentially being written to by multiple processes at the same time. If you want to ensure that every addition to a counter is processed in serial, you can't use normal actions that process data in a read-modify-write cycle, as concurrent executions of the action might overwrite each other's newer data. Instead, passing an atomic operation to the internal API guarantees that these counter increments or decrements are each processed in serial within the database, ensuring data consistency.
Atomic increments are triggered by passing an _atomics: { someField: { increment: 10 } }
input object alongside any other fields you want to update in a single create
or update
mutation.
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 someNumericField: { increment: 5 },4 },5 // can also pass other fields for normal updates6 name: "new name",7});8// is now 5 greater than it was before the call9console.log(record.someNumericField);
1mutation IncrementExample($id: GadgetID!, $amount: Float!) {2 internal {3 updateExampleModel(4 id: $id5 exampleModel: { _atomics: { someNumericField: { increment: $amount } } }6 ) {7 success8 errors {9 message10 }11 exampleModel12 }13 }14}
{ "id": 10, "amount": 42 }
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 someNumericField: { increment: 5 },4 },5 // can also pass other fields for normal updates6 name: "new name",7});8// is now 5 greater than it was before the call9console.log(record.someNumericField);
Incrementing a field who's value is currently null will apply as if that field started at 0.
1const record = await api.internal.exampleModel.create({2 someNumericField: null,3});4await api.internal.exampleModel.update(record.id, {5 _atomics: {6 someNumericField: { increment: 5 },7 },8});9// is now set to 510console.log(record.someNumericField);
1mutation IncrementExample($id: GadgetID!, $amount: Int!) {2 internal {3 updateExampleModel(4 id: $id5 exampleModel: { _atomics: { someNumericField: { increment: $amount } } }6 ) {7 success8 errors {9 message10 }11 exampleModel {12 id13 someNumericField14 }15 }16 }17}
{ "id": 10, "amount": 5 }
1const record = await api.internal.exampleModel.create({2 someNumericField: null,3});4await api.internal.exampleModel.update(record.id, {5 _atomics: {6 someNumericField: { increment: 5 },7 },8});9// is now set to 510console.log(record.someNumericField);
Atomically decrement a record field
Similarly to the increment operator, records can be updated using the atomic decrement operators for consistent in-database updates to numeric fields. Atomic decrements are triggered by passing an _atomics: { field: "someField", decrement: 10 }
input object alongside any other fields you want to update in a single create
or update
mutation.
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 someNumericField: { decrement: 5 },4 },5 // can also pass other fields for normal updates6 name: "new name",7});8// is now 5 less than it was before the call9console.log(record.someNumericField);
1mutation DecrementExample($id: GadgetID!, $amount: Int!) {2 internal {3 updateExampleModel(4 id: $id5 exampleModel: { _atomics: { someNumericField: { decrement: $amount } } }6 ) {7 success8 errors {9 message10 }11 exampleModel {12 id13 someNumericField14 }15 }16 }17}
{ "id": 10, "amount": 42 }
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 someNumericField: { decrement: 5 },4 },5 // can also pass other fields for normal updates6 name: "new name",7});8// is now 5 less than it was before the call9console.log(record.someNumericField);
Decrementing a field who's value is currently null will apply as if that field started at 0.
1const record = await api.internal.exampleModel.create({2 someNumericField: null,3});4await api.internal.exampleModel.update(record.id, {5 _atomics: {6 someNumericField: { decrement: 5 },7 },8});9// is now set to -510console.log(record.someNumericField);
1mutation IncrementExample($id: GadgetID!, $amount: Int!) {2 internal {3 updateExampleModel(4 id: $id5 exampleModel: { _atomics: { someNumericField: { decrement: $amount } } }6 ) {7 success8 errors {9 message10 }11 exampleModel {12 id13 someNumericField14 }15 }16 }17}
{ "id": 10, "amount": 5 }
1const record = await api.internal.exampleModel.create({2 someNumericField: null,3});4await api.internal.exampleModel.update(record.id, {5 _atomics: {6 someNumericField: { decrement: 5 },7 },8});9// is now set to -510console.log(record.someNumericField);
Atomically change multiple fields
The internal API supports changing multiple fields simultaneously by passing multiple field names in the _atomics
input object. Each field in the input can accept one command or a list of commands.
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 visitorCount: { increment: 1 },4 availableTickets: [{ decrement: 1 }, { decrement: 0 }],5 },6 // can also pass other fields for normal updates7 name: "new name",8});9// is now 1 more than it was before the call10console.log(record.visitorCount);11// is now 1 less than it was before the call12console.log(record.availableTickets);
1mutation MultipleAtomicsExample($id: GadgetID!) {2 internal {3 updateExampleModel(4 id: $id5 exampleModel: {6 _atomics: {7 visitorCount: { increment: 1 }8 availableTickets: [{ decrement: 1 }, { decrement: 0 }]9 }10 }11 ) {12 success13 errors {14 message15 }16 exampleModel {17 id18 visitorCount19 availableTickets20 }21 }22 }23}
{ "id": 10 }
1const record = await api.internal.exampleModel.update(10, {2 _atomics: {3 visitorCount: { increment: 1 },4 availableTickets: [{ decrement: 1 }, { decrement: 0 }],5 },6 // can also pass other fields for normal updates7 name: "new name",8});9// is now 1 more than it was before the call10console.log(record.visitorCount);11// is now 1 less than it was before the call12console.log(record.availableTickets);