Models
Apps/Build/Models

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

TypeStoresNotes
stringText
richTextTextAlso accepts { markdown: "…" } and stores the text
emailTextMust look like an email address
urlTextMust be an http(s) URL
colorTextA hex color like #1a2b3c
numberA number
dateTimeAn ISO 8601 instantStored and returned in UTC
enumOne of optionsA list of them with allowMultiple: true
booleantrue or false
roleListA list of role names
jsonAny JSON
belongsToA record's idChecked against the parent model on write
passwordA hashHashed with scrypt on write; never returned
encryptedStringText, encryptedDecrypted when read; can't be filtered or sorted on
vectorA list of numbersUp to 4,000 dimensions. See Vector fields
hasOne, hasMany, hasManyThroughNothingDescribe the other side of a belongsTo; read through them with select
fileA file, in storageRead as { url, fileName, mimeType, size }. See File fields
computedNothingWorked out when read, from an expression or an aggregate over a hasMany. See Computed fields

Field options

OptionMeaning
storageKeyWhere 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.
descriptionOne 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.
defaultThe value of a new record that doesn't give one, and of older records written before the field existed.
optionsAn enum's allowed values.
allowMultipleAn enum that holds a list of options.
parent, child, children, throughA relationship's other model: { model: "user" }, with belongsToField naming the field on the other side. See Relationships.
source, embeddingModel, dimensionsVector fields.
expression, aggregateA computed field's value. See Computed fields.
allowPublicAccessA 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).

ValidationMeaning
required: trueMust have a value on create, and can't be emptied later. Empty text and empty lists count as no value.
unique: trueNo 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" } },
}
  • belongsToField names the field on the other model that points back. It can be left out when that model has only one belongsTo to this one.
  • A hasOne with several records pointing back reads as the newest.
  • A hasManyThrough's join model needs a belongsTo to 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 default project (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 URL

A 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:

ValueFor
{ 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 a size takes 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/redact and customers/redact delete 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" },
}
AggregateValue
{ 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, maxThe smallest or largest number, text or date; null when none
filterOnly 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
ValuesField 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
Logicand, or, not (or &&, ||, !)
Functionsconcat(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 with concat, 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

ChangeWhat existing records do
Add a fieldRead the field's default (or null). Filters treat them as holding it.
Remove a fieldThe value is hidden, not deleted. Adding the field back with the same storage key restores it.
Rename a fieldValues follow if the storage key stays the same.
Change a field's typeValues 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 uniqueApplies to writes from then on. Existing values stay until the record is written.
Remove a modelIts 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.

ProblemWhat you can do
Values that can't be readDelete 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 typeConvert them: each is stored in its field's type now, so filters and sorting see what readers see. Records keep their updatedAt.
Hidden valuesDelete 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 emptyBackfill 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 valuesShow 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.