Reading and writing records
createClient reads and writes an environment's records and runs its actions, from the app's own code or from anywhere with a key.
The client
createClient() from @inferencebureau/apps-sdk returns the app's API, with one property per model. Inside the app (pages, route handlers, actions) it needs no options: it uses the environment's token and reaches only that environment's records and actions. Inside an action, api in the context is the same client.
import { createClient } from "@inferencebureau/apps-sdk";
// In the app's own code every option comes from its environment variables
// (IB_API_URL, IB_ENVIRONMENT_TOKEN, IB_APP, IB_ENVIRONMENT).
const api = createClient();
const review = await api.review.findOne("rec_…"); // or null
const page = await api.review.findMany({
filter: { shop: { equals: "inference-bureau.myshopify.com" }, status: { equals: "pending" } },
sort: { createdAt: "Ascending" },
first: 50,
});
const product = await api.shopifyProduct.findFirst({ filter: { handle: { equals: "tee" } } });The client is a thin wrapper over the REST routes, using only fetch. Pass url to reach another API origin and fetch to supply your own.
Types
Inference Bureau writes web/ib-client.d.ts from the environment's schemas and actions whenever one changes: ib-apps dev and ib-apps pull bring it down, and a deploy carries it to production. It fills in the SDK's types, so createClient(), an action's api and a session's api know every model, its fields, its filters and sortable fields, and its actions and their params. A misspelled model, field, enum value or action fails the type check, and so the deploy's build, instead of the request. Don't edit the file: it's rewritten, and put back if it's changed or deleted.
import { createClient, type ModelInput, type ModelRecord } from "@inferencebureau/apps-sdk";
const api = createClient();
const r = await api.review.findFirst({ filter: { status: { equals: "aproved" } } });
// ~~~~~~~~~ not one of "pending" | "approved" | "rejected"
r?.ratng; // Property 'ratng' does not exist on type 'Review'. Did you mean 'rating'?
await api.review.run("aprove"); // not one of "approve" | "create" | "delete" | "reject"
// A model's types by name, from web/ and api/ alike.
type Review = ModelRecord<"review">;
const draft: ModelInput<"review"> = { rating: 5, status: "pending" };
// A model the app may not have (a Shopify model it syncs only once chosen): typed when it exists.
await api.$model("shopifyProduct").findOne(id);| Field type | Reads as | Writes take |
|---|---|---|
| string, rich text, email, URL, color, encrypted | string | null | string | null |
| number, boolean | number | null, boolean | null | the same |
| date-time | string | null | string | Date | null |
| enum | its options, or a list of them with allowMultiple | the same |
| belongsTo | the parent's id | string or { _link: id } |
| file | FileValue | null | FileInput | null |
| computed | its value's type | never written |
| password | never read | string | null |
| json | unknown | unknown |
Every field is optional in writes and may read null: records written before a field was required, or before it existed, can lack it. An action's params come from its export const params, a plain object literal of { type, required?, options?, properties?, items? }; a create or update action takes the model's fields besides, and an action without params takes any. Params aren't checked at run time. What run returns types as unknown with returnType: true. An action's own record isn't typed, since a file doesn't say which model's it is. An app whose file has no models yet keeps the untyped client.
Methods
| Method | Does |
|---|---|
findOne(id, { select }) | One record, or null. |
findMany({ filter, search, sort, first, after, skip, select }) | A page: { records, hasNextPage, endCursor }. |
findFirst({ filter, sort, select }) | The first match, or null. |
search(field, near, options) | Nearest records to text or a vector. See Vector search. |
aggregate({ groupBy, aggregates, filter, search, sort, first, timeZone }) | Counts, sums, averages, least and most, in groups: { groups, hasMore }. See Aggregates. |
create(values) | Creates a record and returns it as saved. |
update(id, values) | Changes the given fields and returns the record. |
delete(id) | Deletes the record. |
upsert({ on, values }) | Updates the record whose on field (or fields) hold the values' values, or creates one: { record, created }. See Bulk writes and upserts. |
bulkCreate(values[]), bulkUpdate([{ id, values }]), bulkDelete(ids), bulkUpsert({ on, records }) | Up to 250 records in one call: { records, failed, counts } (deleted for a delete). See Bulk writes and upserts. |
build(values) | In an action only: a new record with its id, written when run calls save on it, with the action's own record. See Writing other records with the run. |
checkPassword(id, password, field? | { field?, email?, ip? }) | Whether the password matches the one stored in the record's password field. Web apps' sign-in uses it; hashes are never returned. Failures are counted per email (the record without one) and per visitor ip: after 10 for an email or 50 for an IP within 15 minutes, it throws a 429 ApiError without checking until the window ends. A correct password clears the email's count. |
run(action, { id, params, shop }) | Runs one of the model's actions: { record, result? }. See Calling an action. |
createUpload(field, { fileName, mimeType, size }) | A direct upload for a file field: { url, method, headers, token, expiresAt }. PUT the file to url, then write { directUploadToken: token }. See File fields. |
upload(field, file, { fileName, mimeType }) | Uploads a file straight to storage and returns the value to write to the field. |
api.$global(action, params) | Runs a global action: { result? }. |
api.$model(name) | A model by name, for one the app may not have: typed like api.<model> when it exists. See Types. |
api.enqueue(action, params, options) | Queues a global action, or model.action (with id in options), as a background job and returns the job at once. See Background jobs. |
api.enqueueMany(action, [{ id, params }], options) | Queues up to 250 jobs for one action in one call, the options shared, and returns them in order. |
Records come back with every field the schema has now, in schema order, defaults filled in, unless select names the fields to read (see Related records). Password fields are never returned, and vector fields are left out of lists unless asked for (vectors=full on the REST routes) or selected.
Filters
A filter is an object shaped { field: { operator: value } }, with AND, OR and NOT lists.
| Operator | Matches |
|---|---|
equals, notEquals | The value (or not). equals: null matches records with no value. |
in, notIn | Any value in the list (or none). An empty list matches nothing (or everything). |
lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual | Numbers as numbers; dates as instants; text by text order. |
isSet | true: has a value. false: doesn't. |
startsWith | Text that starts with the value, matching case. |
contains | Text that holds the value anywhere, any case. Text fields only (string, rich text, email, URL, enum) and id. |
// Fields side by side are AND
{ shop: { equals: shop }, rating: { greaterThanOrEqual: 4 } }
// OR, AND and NOT take lists of filters
{ OR: [{ status: { equals: "approved" } }, { moderatedBy: { isSet: false } }] }
{ NOT: [{ status: { in: ["rejected", "pending"] } }] }
// id, createdAt and updatedAt can be filtered and sorted like any field
{ createdAt: { greaterThan: "2026-10-01T00:00:00Z" } }Values are checked against the field's type, so { rating: { equals: "high" } } is refused. A record that never stored a field matches as if it held the field's default: a new boolean field defaulting to false matches { equals: false } on every older record. Password, encrypted, vector and file fields can't be filtered or sorted on.
Filters reach through relationships, so “reviews of Acme's products” is one query. A belongsTo or hasOne takes a filter on its record's fields; a belongsTo given an operator, like { product: { equals: id } }, still filters on the id it holds. A hasMany or hasManyThrough takes some (at least one of its records matches), every (none doesn't, so a record without any matches) or none. A belongsTo also sorts by its record's fields, records without one last.
// A belongsTo or hasOne takes a filter on its record's fields
await api.review.findMany({ filter: { product: { vendor: { equals: "Acme" } } } });
// A hasMany or hasManyThrough takes some, every or none
await api.shopifyProduct.findMany({ filter: { reviews: { some: { rating: { lessThan: 3 } } } } });
await api.shopifyProduct.findMany({ filter: { reviews: { none: {} } } }); // products without a review
// A belongsTo sorts by its record's fields
await api.review.findMany({ sort: [{ product: { title: "Ascending" } }, { createdAt: "Descending" }] });Up to 3 relationships deep. Related records are reached through the same store and role as the call: those it can't read don't count, and a model its role can't read is refused.
Sorting
sort: { rating: "Descending" }, or a list for several keys: [{ status: "Ascending" }, { createdAt: "Descending" }]. Records with no value sort last either way. Without a sort, newest first.
Related records
select reads only the fields it names, and follows relationships, so a page comes with what it needs from other models in one call instead of one call per record. A relationship is true for its records with every field, or { select } to choose theirs; a hasMany or hasManyThrough also takes filter, sort and first.
// A page of reviews with each one's product: one call, not one per review.
const { records } = await api.review.findMany({
filter: { status: { equals: "approved" } },
select: { rating: true, body: true, product: { select: { title: true, handle: true } } },
});
records[0]?.product?.title; // a belongsTo reads as its record, or null
records[0]?.createdAt; // Property 'createdAt' does not exist: it wasn't selected
// A product with its five newest approved reviews, and each review's author.
const product = await api.shopifyProduct.findOne(id, {
select: {
title: true,
reviews: {
select: { rating: true, author: { select: { name: true } } },
filter: { status: { equals: "approved" } },
sort: { createdAt: "Descending" },
first: 5,
},
},
});
product?.reviews.records; // and product.reviews.hasNextPage| Field | Selected as | Reads as |
|---|---|---|
| any other field | true | its value, as without select |
| belongsTo | true or { select } | the parent record instead of its id, or null when there's none or the call can't read it |
| hasOne | true or { select } | the record pointing back (the newest, if there are several), or null |
| hasMany, hasManyThrough | true or { select, filter, sort, first } | { records, hasNextPage }: 50 unless first says (at most 250) for each record, newest first unless sorted |
- The id always comes; nothing else does unless named. Without
select, a read has every field and no related records. - Related records are read a relationship at a time for the whole page, up to 3 relationships deep and 5,000 related records in one call. For more than one page of a record's children, page through the child model with a filter on its
belongsTo. - They're read with the call's own store and role: a role sees on each model only what its grant there lets it, and selecting a model it can't read is refused (403). A
hasManyThroughreads its join model through the grant too. - The generated types follow it: a read has only what it selected, and a misspelled field in a select fails the type check.
- How each relationship is declared: Relationships.
Pagination
first is the page size: 50 by default, at most 250. Pass the page's endCursor as after to get the next one.
For a page by number, or a library that asks for an offset, pass skip instead: the records to pass over, after the filter and sort, up to 10,000. Past that, page on with after. skip and after together are refused. Cursors are the fast way to read every page; a deep skip still reads the records it passes over.
let after: string | null = null;
do {
const page = await api.review.findMany({ filter, first: 250, after });
for (const r of page.records) handle(r);
after = page.hasNextPage ? page.endCursor : null;
} while (after);Text search
search in findMany finds records whose text fields (string, rich text, email, URL and enum) together hold every word, in any case; part of a word counts, so runn finds “running”. Without a sort, the best matches come first: the words together as typed beat the words apart, a word that starts or is a whole word beats one inside another word (runn ranks “Running shoe” over “Brunner”), and a match in a short field (string, email, URL, enum) beats one in rich text; ties go newest first. Filters apply as usual. Without an index it reads every record of the model the filter leaves, so narrow large models with a filter (a store, a status) too.
// Every word in the model's text fields, any case; part of a word counts.
const { records } = await api.review.findMany({
search: "runs small",
filter: { status: { equals: "approved" } },
});
// The same words anywhere in one field.
await api.review.findMany({ filter: { body: { contains: "runs small" } } });On the REST routes it is the search query parameter (?search=runs%20small) or the same in a …/query body; the MCP server's find_records and count_records take it too. For search by meaning, use a vector field.
Indexes
Every model's records share one table with an index that serves equals and in on any field. Everything else, a range (greaterThan, lessThan), a sort on a field, startsWith, contains and text search, reads every record the other filters leave. That is fine for thousands of records and slow past a few hundred thousand in one model. A schema can declare indexes for the reads that matter:
// api/models/review/schema.ib.ts
export const schema: ModelSchema = {
type: "ib/model-schema/v1",
fields: { /* … */ },
indexes: [
{ fields: ["shop", "createdAt"] }, // a shop's reviews, newest first or in a date range
{ fields: ["shop", "productGid"], unique: true }, // one review per product per shop
{ fields: ["title"], search: true }, // startsWith and contains on title
{ search: true }, // the model's text search
],
};{ fields: [...] }servesequalsandinon its first field, then ranges and anAscendingsort on the next (aDescendingsort still orders the rows the equality leaves). Fields can be string, email, url, color, enum (one option), number, boolean, dateTime, belongsTo,id,createdAtorupdatedAt, up to eight.unique: truekeeps the fields' values unique together, checked by the database: a write that repeats them fails withis already taken by another recordon the last field, as a field'svalidations.uniquedoes. A unique index on one field takes over that field's check.search: truewith fields servesstartsWithandcontainson each of them; alone, it serves the model's text search. Text fields only (rich text included).
Indexes are metadata like the rest of the schema: saving or deploying a schema never runs anything against the data. Each index is built in the background within a minute or two, without blocking writes, and removed the same way when its declaration goes; renaming a field keeps its index. Until it is built, reads work as before. The model's stored-data panel in Files shows each index that isn't serving yet and why: a unique index can't be built while records already share a value (fix them, and it's built again within the hour), and a btree can't hold a value past about 2.5 KB. A declaration that names a field that doesn't exist, or a type that can't be indexed, is listed there and left alone. The description of a model (GET …/models/review) lists its indexes with their state.
You don't have to guess which reads need one. When a read through the API, an action's api or the MCP server sorts, takes a range, a startsWith or contains, or searches a model of 50,000 records or more without an index that serves it, and is slow, Problems shows a warning on the model's schema: what the read did, how often it has happened and the slowest time, and the entry to add, such as { fields: ["shop", "createdAt"] } or { search: true }. A route called thousands of times makes one warning with a count. It goes once the index is built, or after a week without that read. This works in production too, where the big models are, and the deploy plan lists production's warnings that the deploy doesn't declare an index for.
Vector search
search orders records by cosine distance from text or a vector, nearest first, and adds _distance to each. Text is embedded with the field's embeddingModel and billed like any embedding: to the caller's key, or to the team's Apps key from the app's own code. Filters apply as usual; sorting doesn't. Only records whose vector has the same number of dimensions as the query are compared.
// Nearest records to some text (embedded with the field's model), with normal filters.
const { records } = await api.shopifyProduct.search("embedding", "red running shoes", {
filter: { status: { equals: "ACTIVE" } },
first: 10,
});
records[0]._distance; // cosine distance, smallest first
// Or to a vector you already have.
await api.shopifyProduct.search("embedding", [0.012, -0.08 /* … */]);See Vector fields for how vectors are kept current.
Aggregates
aggregate answers questions about many records without reading them: how many, how much, on average, the least and the most, in groups. Each entry of aggregates is named as the answer names it, and is { count: true } (records), { count: "field" } (records holding a value in it), or { sum | avg | min | max: "field" }. Sums and averages take number fields, computed ones included; least and most take dates and text too. A record that never stored a field counts as holding its default, as in a filter.
// Reviews and their average rating, per product per month.
const { groups } = await api.review.aggregate({
groupBy: ["product", { field: "createdAt", interval: "month" }],
aggregates: { reviews: { count: true }, rating: { avg: "rating" } },
filter: { status: { equals: "approved" } },
timeZone: "America/New_York",
});
// [{ product: "rec_…", createdAt: "2026-10", reviews: 12, rating: 4.25 }, …]
// No groupBy: one group of all the records the filter leaves.
const [{ revenue, orders }] = (await api.order.aggregate({
aggregates: { revenue: { sum: "total" }, orders: { count: true } },
filter: { createdAt: { greaterThanOrEqual: "2026-10-01T00:00:00Z" } },
})).groups;groupBytakes up to four fields: text, numbers, booleans, single-choice enums, dates and belongsTo (as the related record's id). A date groups by{ field, interval }:hour,day,week(starting Monday),month,quarteroryear, intimeZone(UTC unless given). Buckets read as labels that sort in order:2026-10-21T14:00,2026-10-21,2026-10,2026-Q4,2026; a week is the date of its Monday. WithoutgroupBythere's one group of every record the filter leaves.filterandsearchwork as infindMany, through relationships too. Calls held to a store or a role (forShop,asRole, a page's session) count only the records they could read.sortorders the groups by a field they're grouped by or an aggregate's name,{ reviews: "Descending" }, or a list of them; groups come by their values, ascending, otherwise. Groups with no value come last.firstgroups come back (100 unless given, at most 1000), andhasMoresays when there are more: narrow the filter, or group less finely.
On the REST routes it is POST …/models/{model}/records/aggregate with the options as the body; the MCP server has aggregate_records, and pages have useAggregate. The work is one query in the database; like a filter on a field, a big model reads faster with an index on what the filter narrows by.
A report the app uses in several places is a global action that returns an aggregate: pages call it by name (useGlobalAction, or api.$global), and its query lives in one file.
// api/actions/reviewsByMonth.ts: a report the app's pages call by name.
import type { ActionParams, GlobalActionRun } from "@inferencebureau/apps-sdk";
export const params: ActionParams = { product: { type: "string", required: true } };
export const run: GlobalActionRun = async ({ api, params }) =>
api.review.aggregate({
groupBy: [{ field: "createdAt", interval: "month" }],
aggregates: { reviews: { count: true }, rating: { avg: "rating" } },
filter: { product: { equals: params.product } },
});
export const options = { returnType: true };Writes run actions
create, update and delete run the model's action of the same name when it has one (api/models/<model>/actions/create.ts and so on), with the values as its params. Without one, the write is applied directly. The same goes for the REST routes and the MCP server; edits in the dashboard's record grid are applied directly. Each write is an action event in Logs.
Writes through the client happen at once. Inside an action, they are separate from the action's own record, which is saved when run returns (see The action's record).
Bulk writes and upserts
Loading an existing dataset, syncing a third-party source or cleaning up a model shouldn't take one call per record. bulkCreate, bulkUpdate, bulkDelete and bulkUpsert take up to 250 records (a page's worth) in one call, and upsert writes one record by a field's value instead of its id.
// Up to 250 at once. The answer says which records failed and why; the rest are saved.
const made = await api.product.bulkCreate(rows.map((r) => ({ title: r.name, sku: r.sku, price: r.price })));
made.counts; // { succeeded: 248, failed: 2 }
made.records[0]; // the first record as saved, or null where it failed
for (const f of made.failed) console.warn(f.index, f.error.code, f.error.problems);
await api.product.bulkUpdate([{ id: "rec_…", values: { price: 12 } }, { id: "rec_…", values: { price: 15 } }]);
const gone = await api.product.bulkDelete(ids); // { deleted, failed, counts }
// Upsert: the record whose sku matches is updated, else one is created.
const { record, created } = await api.product.upsert({ on: "sku", values: { sku: "T-1", title: "Tee", price: 10 } });
const synced = await api.product.bulkUpsert({ on: ["shop", "sku"], records: rows });
synced.counts; // { succeeded, failed, created, updated }- Each record goes the way a single write goes. The model's
create,updateordeleteaction runs per record when it has one; otherwise the writes are applied directly, all in one transaction, with each refused record rolled back on its own so the rest still land. Each write is its own event in Logs. - A batch isn't all-or-nothing by accident. The answer is
{ records, failed, counts }({ deleted, failed, counts }for a delete):recordsholds each record as saved, in order, withnullwhere one was refused, andfailednames each refused record'sindex(andid) with the same error a single write would throw:code,message,problemsfor invalid values,eventandlogsfor an action that threw. The call itself throws only for a malformed batch (more than 250, a missingid). - Upserts find the record by
on: a stored field orid, or a list of fields that together identify it (["shop", "sku"]), each with a value invalues. The match (the newest, if several) is updated, else a record is created;createdsays which, and a bulk upsert's counts split intocreatedandupdated. Upserts of the same key wait for one another, so two at once can't both create; give the field a unique validation too, so nothing else can make a duplicate. - A store's or role's client (
forShop,asRole) holds every record of the batch as it holds a single write: new records are the store's, and a record it can't reach is reported as not found. - On the REST routes these are
POST …/models/{model}/bulkand…/records/upsert; the MCP server'screate_recordtakes a list. To run many jobs instead,api.enqueueManyqueues up to 250 for one action in one call (see Background jobs).
Import and export CSV
A model's records go out and come in as CSV, to bring data from a spreadsheet or another platform, move it between environments, or keep a copy. In the dashboard, Export CSV and Import CSV sit beside the record grid's pages; the CLI and the REST API do the same.
ib-apps records export product --out product.csv
ib-apps records import product product.csv --on sku # shows the plan, then asks
curl -X POST "$IB_API/v1/apps/shop/environments/development/models/product/import?on=sku&apply=true" \
-H "authorization: Bearer $IB_API_KEY" -H "content-type: text/csv" --data-binary @product.csv- An export has a column for
id, each stored field andcreatedAt,updatedAt, a row per record in id order (the grid's filter and sort when it comes from there). A file is its URL, abelongsToits parent's id, and json, lists and vectors are JSON. Passwords never leave, and a vector with asourceis left out: it's embedded again from its text. Text a spreadsheet would run as a formula (starting=,+,-or@) gets a'before it, which an import takes off. - An import is planned first. The header's names are matched to fields (case doesn't matter), each cell is read as its field's type, and the plan lists the columns left out, the rows with problems and why, and how many rows create a record and how many update one. Nothing is written until you import. An empty cell leaves the field out: a new record takes its default, an existing one keeps its value.
- Rows are matched to records by
idwhen the file has an id column, or by a field with a unique validation (on); a match is updated, the rest are created. Matched by id, a new record keeps the file's id, so an export from development imports into production with itsbelongsTolinks intact. With nothing to match by, every row creates a record. Two rows with the same key are a problem in the file, not two writes. - It runs in the background, 200 rows at a time; the grid's Stored data bar follows it. Rows with problems, and writes the database refuses, are left out and listed; the rest land. By default rows are written directly, as one
product importevent in Logs. With actions on, each row is queued as a job for the model'screateorupdateaction instead (see Background jobs). Backups can put the model back to before the import. - A file is at most 4 MB and 50,000 rows; split a bigger one. A model takes one run at a time, so an import waits for a cleanup or backfill to finish. On the REST routes:
GET …/models/{model}/export.csv, andPOST …/models/{model}/import, which answers the plan, or withapplystarts the run.
The app's own files
A file that belongs to no record, such as an export, a generated image or an asset an action makes, goes in the app's storage by path: api.files. There is nothing to set up, and the files count in the app's storage like its records' file fields do. A file a record owns belongs in a file field instead, which keeps it with the record, its history and its redaction.
// An export, written from an action and handed out by its signed URL (it lasts an hour).
const csv = await api.files.put("exports/reviews-2026-10.csv", text, { mimeType: "text/csv" });
csv.url;
// Under public/ a file is on the CDN with a lasting URL anyone can open.
const hero = await api.files.put("public/images/hero.png", bytes);
const { files, hasNextPage, endCursor } = await api.files.list({ prefix: "exports/" });
await api.files.get("exports/reviews-2026-10.csv"); // null when there's none
await api.files.delete("exports/reviews-2026-10.csv");
// From a browser: a route makes the upload, the page PUTs the file to its url with its headers.
const upload = await api.files.createUpload("imports/orders.csv", { mimeType: "text/csv", size });| Method | Does |
|---|---|
put(path, body, { mimeType }) | Stores a string, bytes or a Blob at the path, replacing a file there, up to 1 GB: over 4 MB it goes straight to storage. The type is mimeType, else the Blob's, else guessed from the path. Returns the file. |
get(path) | The file, { path, access, url, size, mimeType, uploadedAt }, with a fresh url; null when there's none. |
list({ prefix, first, after }) | The files under prefix (all of them without one), private ones first: a page of first (100 unless given, up to 1,000) as { files, hasNextPage, endCursor }. |
delete(path) | Deletes the file; nothing happens when there's none. |
createUpload(path, { mimeType, size }) | A direct upload for a browser to PUT to: { url, method, headers, expiresAt }, good for an hour, taking at most size bytes when given. The file lands at the path; nothing to write afterwards. |
- A path is segments of letters, digits,
.,_and-joined by/, up to 512 characters. A path names one file: writing it again replaces it. - Under
public/a file is on the CDN with a lasting URL that anyone with it can open. Anywhere else it is private and reads through a URL signed for an hour, so read it again for a fresh one rather than storing the URL. - Storage has no permissions of its own: an
apiheld to a role (a session's,asRole) can't reach it. A route reads or writes the file as the app and hands the browser the file'surl. - On the REST routes these are
GET,PUTandDELETE …/storage/{path},GET …/storage?prefix=andPOST …/storage/uploads. Each environment's files are its own.
Restoring records
Every write to an environment's records is kept as history, whatever made it (the client, an action, the dashboard, a Shopify sync), for the plan's days: 1 on Free, 7 on Pro, 30 on Scale. An owner or admin can put the environment's records, or one model's, back as they were at any moment in that window from Settings → Backups, preview what changes first, and undo the restore afterwards. A restore runs no actions. There's no API or client method for it. See Backups and restore.
Errors
The client throws ApiError with the HTTP status, the error code and, for a refused write, problems: each field and what's wrong with it, so a form can show them beside the fields.
import { ApiError } from "@inferencebureau/apps-sdk";
try {
await api.review.create({ rating: 9, body: "Great" });
} catch (e) {
if (e instanceof ApiError) {
e.status; // 400
e.code; // "invalid_request"
e.problems; // [{ field: "rating", message: "must be at most 5" }, { field: "body", message: "must have a length of at least 10" }, …]
}
}| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A field's value, a filter or a sort is wrong. problems says which. |
| 404 | not_found | No such model, record or action, or no such app for this key. |
| 422 | action_failed | The action threw. The body has event (its id in Logs) and logs. |
| 503 | dependency_unavailable | The app's code isn't answering: development's dev server is starting, or production isn't deployed. Also when the team's apps are paused, or for development during a grace period (see Plans and usage). |