Models
A model is a kind of record, described by one schema file. Changing it never runs a migration: records are stored as JSON and read through the schema in force.
The schema file
Each model is a folder under api/models/ with a schema.ib.ts. The folder name is the model's name, used in the API and the client (api.review). The file exports schema, a plain object literal: strings, numbers, booleans, null, lists and objects.
import type { ModelSchema } from "@inferencebureau/apps-sdk";
export const schema: ModelSchema = {
type: "ib/model-schema/v1",
fields: {
product: { type: "belongsTo", storageKey: "rvProduct", parent: { model: "shopifyProduct" } },
productGid: { type: "string", storageKey: "rvProductGid", validations: { required: true, regex: { pattern: "^gid://shopify/Product/\\d+$" } } },
shop: { type: "string", storageKey: "rvShop", validations: { required: true } },
rating: { type: "number", storageKey: "rvRating", validations: { required: true, range: { min: 1, max: 5 } } },
body: { type: "string", storageKey: "rvBody", validations: { required: true, stringLength: { min: 10, max: 4000 } } },
authorEmail: { type: "email", storageKey: "rvAuthorEmail" },
status: { type: "enum", storageKey: "rvStatus", options: ["pending", "approved", "rejected"], default: "pending" },
moderatedAt: { type: "dateTime", storageKey: "rvModeratedAt" },
},
};Every record also has id (rec_…), createdAt and updatedAt, set by Inference Bureau; a field can't take those names. In the dashboard, Files shows schema as a field editor and data as a record grid. The editor rewrites the literal and leaves the rest of the file as it was; a schema that uses a variable, a call or a spread can only be edited as code.
Field types
| Type | Stores | Notes |
|---|---|---|
string | Text | |
richText | Text | Also accepts { markdown: "…" } and stores the text |
email | Text | Must look like an email address |
url | Text | Must be an http(s) URL |
color | Text | A hex color like #1a2b3c |
number | A number | |
dateTime | An ISO 8601 instant | Stored and returned in UTC |
enum | One of options | A list of them with allowMultiple: true |
boolean | true or false | |
roleList | A list of role names | |
json | Any JSON | |
belongsTo | A record's id | Checked against the parent model on write |
password | A hash | Hashed with scrypt on write; never returned |
encryptedString | Text, encrypted | Decrypted when read; can't be filtered or sorted on |
vector | A list of numbers | Up to 4,000 dimensions. See Vector fields |
hasOne, hasMany, hasManyThrough | Nothing | Describe the other side of a belongsTo; read through them with select |
file | A file, in storage | Read as { url, fileName, mimeType, size }. See File fields |
computed | Nothing | Worked out when read, from an expression or an aggregate over a hasMany. See Computed fields |
Field options
| Option | Meaning |
|---|---|
storageKey | Where the field's value is kept. Without one, the field's name is used. Keep it when you rename a field and the values follow the new name. The schema editor gives every new field a random one. |
description | One line on what the field holds, for the app's API reference, its generated types (as a doc comment) and its MCP server. A schema takes a description next to type too: what a record is. |
default | The value of a new record that doesn't give one, and of older records written before the field existed. |
options | An enum's allowed values. |
allowMultiple | An enum that holds a list of options. |
parent, child, children, through | A relationship's other model: { model: "user" }, with belongsToField naming the field on the other side. See Relationships. |
source, embeddingModel, dimensions | Vector fields. |
expression, aggregate | A computed field's value. See Computed fields. |
allowPublicAccess | A file field whose files are public, served from a CDN. See File fields. |
A model can set its own storageKey next to type too. Its records are filed under it instead of the folder name, so a model folder can be renamed without losing them.
Validations
Validations are checked on every write, against the record as it will be after the write. A write that fails any of them is refused as a whole, with each field's problem (see Errors).
| Validation | Meaning |
|---|---|
required: true | Must have a value on create, and can't be emptied later. Empty text and empty lists count as no value. |
unique: true | No other record of the model may hold the same value. Checked under a lock, so two writers can't both take a value. |
range: { min, max } | A number's bounds. |
stringLength: { min, max } | A text's length bounds. |
regex: { pattern } | A JavaScript regular expression the text must match. |
A record's stored JSON may be at most 256 KiB.
Relationships
A belongsTo field holds the id of a record of its parent model, and a write is refused if that record doesn't exist. The other side is described with hasMany, hasOne or hasManyThrough, which store nothing. A read follows any of them with select, for every record of a page in one call (see Related records); to page through many children, filter the child model on its belongsTo field.
// api/models/user/schema.ib.ts
fields: {
email: { type: "email", validations: { required: true, unique: true } },
sessions: { type: "hasMany", children: { model: "session", belongsToField: "user" } },
}
// api/models/session/schema.ib.ts
fields: {
user: { type: "belongsTo", parent: { model: "user" } },
}belongsToFieldnames the field on the other model that points back. It can be left out when that model has only onebelongsToto this one.- A
hasOnewith several records pointing back reads as the newest. - A
hasManyThrough's join model needs abelongsToto this model and one to the model it reaches. When it has more than those two, name them:through: { model, belongsToSelfField, belongsToSiblingField }.
Vector fields
A vector field holds an embedding. Write it yourself as a list of numbers (set dimensions to have the length checked), or give it a source and Inference Bureau embeds those fields' text, one per line, after each write that changes them.
fields: {
title: { type: "string" },
description: { type: "richText" },
// Embedded from title and description after each write that changes them.
embedding: { type: "vector", source: ["title", "description"] },
// Or pick the model; openai/text-embedding-3-small by default.
// embedding: { type: "vector", source: ["title"], embeddingModel: "openai/text-embedding-3-large" },
}- Embeddings are ordinary embedding requests: admitted by Directives, billed and logged. A write made with an API key embeds as that key. Writes with no key of their own (the app's code, a signed-in CLI, a Shopify install) embed as the team's Apps key, a system key in the team's
defaultproject (or, for a team without one, an internal Apps project left out of the project list), made on first use. - The text's hash is kept, so unchanged text isn't embedded again.
- If the embedding is refused or there is nothing to embed with (a dashboard edit, a write by the assistant, a revoked Apps key), the write still stands, the stale vector is dropped, and Logs show a failed
<model>.embed. - A sourced vector field can't be written directly. Search it with search.
File fields
A file field holds a file: review photos, digital downloads, a CSV to import. The file is kept in storage, apart from the record, which holds its name, type and size. Each environment's files are its own.
photo: { type: "file" }, // private: read through a signed URL
banner: { type: "file", allowPublicAccess: true }, // public: read as a CDN URLA record reads a file as { url, fileName, mimeType, size }. A field's files are private unless it sets allowPublicAccess: true: a private file's url is signed and works for an hour from the read, so read the record again for a fresh one; a public file's is a plain CDN address that anyone with it can open, and lasts as long as the file. A file keeps the access it was stored with when the option is changed later.
A write gives a field a file in one of three ways, and null clears it:
| Value | For |
|---|---|
{ base64, fileName?, mimeType? } | Small files, in the write itself. A request body is at most 4 MB, so about 3 MB of file. A data: URL works as the base64. |
{ copyURL, fileName?, mimeType? } | A public https URL, fetched by Inference Bureau, up to 50 MB. |
{ directUploadToken } | A direct upload, up to 1 GB: the file is PUT straight to storage, then the token is written within a day. upload(field, file) does both from server code; createUpload(field, options) makes one a browser can PUT to. |
// Small files: the bytes as base64, in the write itself (up to about 3 MB).
await api.review.create({ body: "Fits well", photo: { base64, fileName: "tee.jpg", mimeType: "image/jpeg" } });
// A public https URL Inference Bureau copies (up to 50 MB).
await api.review.update(id, { photo: { copyURL: "https://cdn.example.com/tee.jpg" } });
// Anything bigger, or from a browser: a direct upload, up to 1 GB.
await api.review.update(id, { photo: await api.review.upload("photo", file) });
// A browser uploads without the file passing through the app: the app's route makes the upload,
const up = await api.review.createUpload("photo", { fileName: file.name, mimeType: file.type, size: file.size });
// the page PUTs the file to up.url with up.headers, then the route writes the token.
await api.review.update(id, { photo: { directUploadToken: up.token } });
// Reading: { url, fileName, mimeType, size }. A private field's url works for an hour.
const { photo } = await api.review.findOne(id);- Without a
mimeType, the type is the URL's or the upload's, else guessed from the file name's extension. A direct upload given asizetakes a file of at most that size. - File fields can't be filtered or sorted on, and a file's bytes are left out of Logs.
- A replaced file, and a deleted record's, are kept while backups can restore the record, then deleted. So is an upload that was never written. Shopify's
shop/redactandcustomers/redactdelete the erased records' files at once. - Files count toward the environment's storage on the Usage page.
Computed fields
A computed field stores nothing. Its value is worked out each time the record is read, so it's never out of date: a product's review count follows its reviews without an action keeping it in step. It gives either an aggregate over a hasMany's records or an expression over the record's own fields.
// api/models/product/schema.ib.ts
fields: {
title: { type: "string" },
price: { type: "number" },
reviews: { type: "hasMany", children: { model: "review", belongsToField: "product" } },
// Aggregates over the hasMany's records, only those matching the filter if one is given.
reviewCount: { type: "computed", aggregate: { count: "reviews", filter: { status: { equals: "approved" } } } },
averageRating: { type: "computed", aggregate: { avg: "reviews.rating", filter: { status: { equals: "approved" } } } },
lastReviewedAt: { type: "computed", aggregate: { max: "reviews.createdAt" } },
// Expressions over the record's own fields, and other computed fields.
priceWithTax: { type: "computed", expression: "round(price * 1.2, 2)" },
summary: { type: "computed", expression: "concat(title, ' (', reviewCount, ' reviews)')" },
popular: { type: "computed", expression: "reviewCount >= 10 and averageRating >= 4" },
}| Aggregate | Value |
|---|---|
{ count: "reviews" } | How many records the hasMany has; 0 when none |
{ sum: "reviews.rating" } | The total of a number field; 0 when none |
{ avg: "reviews.rating" } | The average of a number field; null when none |
min, max | The smallest or largest number, text or date; null when none |
filter | Only the records matching it, in the filter syntax. It can use the other model's computed fields too. |
The hasMany needs children.belongsToField, the field on the other model that points back, unless that model has only one belongsTo to this one. The field after the dot can be a stored number, text or date field, createdAt, updatedAt, or a computed field of the other model.
| Expressions | |
|---|---|
| Values | Field names (stored, system and computed), numbers, 'text' in single or double quotes, true, false, null |
| Arithmetic | + - * / on numbers. Dividing by zero gives null |
| Comparison | == != < <= > >=. == and != treat null as a value, so note == null is true for a record without one |
| Logic | and, or, not (or &&, ||, !) |
| Functions | concat(a, b, …) joins text and leaves out nulls; coalesce(a, b, …) is the first that isn't null; if(condition, then, else); min and max of their arguments; round(x, digits) (half away from zero), floor, ceil, abs; lower, upper, length |
- Anything with a null in it is null, apart from
concat,coalesce,min,max,==and!=. Join text withconcat, not+. A stored field reads its default when a record has no value for it. - A computed field filters and sorts by its value's type (number, text, true/false or date), with the operators that type has. Filtering or sorting on an aggregate looks at every record's children, so on a large model it is slower than a stored field.
- Computed fields can't be written. An action that wants a value kept as it was at a moment should store it in a field of its own.
- A definition that doesn't work out (a missing field, text added to a number, two fields that use each other) leaves the rest of the model working: the field reads null, the model description says why, and filtering or sorting on it is refused with the reason.
Storage: no migrations
Every record of every model is a row of JSON, keyed by each field's storage key, in the environment's share of one table. Vectors are kept beside them, in Postgres with pgvector. The schema is metadata read from the environment's own files (development's as saved, production's as deployed), and every write is checked, defaulted and typed against it. Nothing in the database enforces the shape, so a schema change never runs DDL and can't fail halfway.
Every row carries its environment's id, so development and production never see each other's records.
Changing a schema
| Change | What existing records do |
|---|---|
| Add a field | Read the field's default (or null). Filters treat them as holding it. |
| Remove a field | The value is hidden, not deleted. Adding the field back with the same storage key restores it. |
| Rename a field | Values follow if the storage key stays the same. |
| Change a field's type | Values convert when read ("12" reads as 12 for a number). Ones that can't convert read as null, and are stored in the new type on their next write. |
| Make a field required or unique | Applies to writes from then on. Existing values stay until the record is written. |
| Remove a model | Its records are hidden. Adding the model back restores them. |
In development these take effect as the file saves. In production they take effect on the next deploy, whose plan lists each one: see Deploys.
Catching stored data up
Above a model's records in Files, a Stored data line appears when what's stored doesn't match the schema: values in an earlier type, values that can't convert, removed fields' hidden values, required fields that records leave empty, and values that records share under a unique rule. Review opens a row for each, with what can be done about it. Deletes ask first.
| Problem | What you can do |
|---|---|
| Values that can't be read | Delete them. A value that can't convert to its field's type reads as null; deleting removes it from storage. Changing the type back or rolling back a deploy no longer restores it. |
| Values in an earlier type | Convert them: each is stored in its field's type now, so filters and sorting see what readers see. Records keep their updatedAt. |
| Hidden values | Delete them. Removed fields' values stay stored until then, so adding a field back with the same storage key restores them. |
| A required field that records leave empty | Backfill it: one value for all of them (not for a unique field), or one of the model's actions queued as a job for each record, which sets it however the action decides. Jobs show in Queue. |
| A unique field's shared values | Show them filters the grid to the records sharing the most common one, to change by hand. |
A run works through the model in batches and keeps going in the background if you leave the page. A record written while the run is going keeps that write, and the run skips it. Each run is one event in Logs. To undo one, restore the model in Settings → Backups to before the run started. That also undoes anything else written to the model since.
Two fields with the same storage key make the schema unreadable rather than mixing their values. A schema that can't be read is listed as a problem, and its model can't be read or written until the file is fixed.