Data config
A resource describes one table of a database you already have: its columns as fields, its key, and the rules around it. It is plain TypeScript in config/<resource>/data.ts, exported from config/index.ts; protobase scaffold writes a first version from the database. Every example on this page is a file in the repository that CI typechecks and tests.
import { f, resource } from 'protobase/schema'
// Pip's bike shop: customers bring bikes in, the workshop repairs them. Two tables of a Postgres database the// shop already has, described as they are.
/** shop.customers */export const customers = resource('customers') .table('shop.customers') .fields({ id: f.integer().readOnly().filterable().sortable().dbDefault(), name: f.text().min(2).filterable().sortable(), phone: f.text().regex(/^\+?[0-9 ]{6,}$/, 'Use digits, spaces and an optional +').optional(), email: f.text().optional().filterable(), country: f.country().default('NL'), createdAt: f.timestamp().readOnly().filterable().sortable().dbDefault(), }) .primaryKey((r) => r.id) .search((r) => [r.name, r.email])
/** shop.repairs */export const repairs = resource('repairs') .table('shop.repairs') .fields({ id: f.integer().readOnly().filterable().sortable().dbDefault(), number: f.text().readOnly().filterable().sortable().dbDefault(), customerId: f.relation('customers').filterable().sortable(), bike: f.text(), problem: f.text(), status: f.enum(['booked', 'waiting', 'working', 'ready', 'collected']).default('booked').filterable().sortable(), mechanic: f.text().optional().filterable().sortable(), estimate: f.decimal({ precision: 8, scale: 2 }).filterable().sortable(), currency: f.currency().default('EUR'), paid: f.boolean().default(false).filterable(), bookedOn: f.date().filterable().sortable(), notes: f.text().optional().column('internal_notes'), legacyRef: null, updatedAt: f.timestamp().readOnly().dbDefault(), }) .primaryKey((r) => r.id) .search((r) => [r.number, r.bike, r.problem]) .validate((repair) => (repair.status === 'collected' && !repair.paid ? [{ field: 'paid', message: 'A bike leaves the shop paid' }] : undefined))resource()
Section titled “resource()”resource(name) |
The name is the resource’s address (/api/v1/<name>, /<name> in the admin) and how other config refers to it. It may not contain /, :, ? or #, and must be unique. |
.table('schema.table') |
The table, with or without its schema. Required. |
.fields({ ... }) |
The columns, by field name. name: null ignores a column on purpose (scaffold writes it, so it does not offer the column again). Required. |
.primaryKey((r) => r.id) |
The key, one field or a list ((r) => [r.orderId, r.lineNo]). Required; records are addressed by it. |
.softDelete((r) => r.deletedAt) |
A nullable timestamp or date: deleting sets it, lists leave such rows out, :undelete clears it. See REST API. |
.tenant((r) => r.organizationId) |
The column that separates tenants: every query is limited to the caller’s tenant, creates set it, and a request body may not. |
.owner((r) => r.ownerId) |
The field holding the owning user’s id, which .own capabilities filter on (see Access). |
.search((r) => [r.number, r.name]) |
The fields that search("...") and bare words in a filter match, and what the admin’s global search looks in. Every word of the text must appear in one of them, ignoring case, and part of a word is enough: port finds “Portfolio”. |
.validate((record) => issues) |
A rule over the whole record, run after the field checks on every create and update. It returns [{ field, message }] or undefined; issues answer 400 with the field named. |
.access({ ... }) |
Rules per operation. See Access. |
Field names are camelCase; the column is the snake_case of the name unless .column() says otherwise.
Field types
Section titled “Field types”| Builder | Column types | API value | Options |
|---|---|---|---|
f.text() |
text, varchar, bpchar |
string | .min(n), .max(n) (length), .regex(re, message) |
f.integer() |
int2, int4 |
number | .min(n), .max(n) |
f.bigint() |
int8 |
string (all 64 bits) | |
f.decimal({ precision, scale }) |
numeric |
string (exact) | |
f.boolean() |
bool |
boolean | |
f.date() |
date |
2026-10-08 |
|
f.timestamp() |
timestamp, timestamptz |
ISO 8601 with offset | |
f.uuid() |
uuid |
string | |
f.enum(['a', 'b']) |
Postgres enum, or text with a CHECK (... IN ...) |
one of the values | |
f.json() |
json, jsonb |
any JSON | |
f.relation('resource') |
the key column(s) of another resource | its key | .columns([...]) |
f.currency() |
text |
ISO 4217 code (EUR) |
|
f.country() |
text |
ISO 3166-1 alpha-2 code (NL) |
A constraint a type does not support (f.boolean().min(1)) throws when the config loads.
Field options
Section titled “Field options”Every option returns a new field, so they chain in any order.
| Option | Effect |
|---|---|
.optional() |
The column is nullable; the value may be null. |
.readOnly() |
Never written through the API: ids, computed columns, audit timestamps. |
.default(value) |
The value a create uses when none is given; the admin’s create form shows it. |
.dbDefault() |
The database supplies a value when none is given (identity, now(), a sequence). |
.filterable() |
May appear in filter, in filter widgets and in Stat/Table filters. Give it an index. |
.sortable() |
May appear in order_by and in sorts. |
.alias(...names) |
Other names filters may use for the field. |
.column('name') |
The column, when it is not the snake_case of the field name ('EMP_NAME'). |
.access({ read, create, update }) |
Role rules for this column. See Access. |
The ERP’s products show most of them, together with soft delete and a tenant:
import { f, resource } from 'protobase/schema'import { erpAccess } from '../roles'
/** catalog.products. Unique: (organization_id, sku) */export const products = resource('products') .table('catalog.products') .fields({ id: f.bigint().readOnly().filterable().sortable().dbDefault(), organizationId: f.relation('organizations').filterable().sortable(), categoryId: f.relation('categories').filterable().sortable(), sku: f.text().filterable().sortable(), name: f.text().filterable(), description: f.text().optional(), price: f.decimal({ precision: 12, scale: 2 }), currencyCode: f.relation('currencies'), attributes: f.json().dbDefault(), active: f.boolean().default(true), deletedAt: f.timestamp().optional(), createdAt: f.timestamp().readOnly().filterable().sortable().dbDefault(), updatedAt: f.timestamp().readOnly().dbDefault(), }) .primaryKey((r) => r.id) .softDelete((r) => r.deletedAt) .tenant((r) => r.organizationId) .access(erpAccess('products'))Keys and relations
Section titled “Keys and relations”A key of several columns is a list, and a record’s address joins its parts with a comma (/stock/CHAIN-11,L). A relation to such a resource names its columns, in the order of that key:
import { f, resource } from 'protobase/schema'
// Keys of more than one column, and a relation that points at one.
/** shop.stock: one row per part and size. */export const stock = resource('stock') .table('shop.stock') .fields({ partNo: f.text().filterable().sortable(), size: f.text().filterable().sortable(), onHand: f.integer().min(0), }) .primaryKey((r) => [r.partNo, r.size])
/** shop.part_orders: parts ordered from suppliers. */export const partOrders = resource('partOrders') .table('shop.part_orders') .fields({ id: f.uuid().readOnly().filterable().dbDefault(), // Two columns hold the key of one stock row, in the order of its primary key. part: f.relation('stock').columns(['part_no', 'part_size']), quantity: f.integer().min(1).max(50), // Filters may say `ref` as well as `supplierReference`. supplierReference: f.text().alias('ref').filterable(), orderedAt: f.timestamp().readOnly().filterable().sortable().dbDefault(), }) .primaryKey((r) => r.id)A relation’s value in the API is the other record’s key. The admin shows the other record’s name instead (its view’s title, else a field such as name or number) and links to it, and a create form picks it from a list.
Access
Section titled “Access”Without rules everyone who can sign in may do everything. Rules come in three kinds, and the Access reference explains the defaults:
- Per operation (
list,read,create,update,delete):.access({ update: rule }). A rule returnstrue,false, or an AIP-160 filter that limits the rows, like row-level security. A rule that reads the record (record.status) is checked against the stored row. - Per row: a filter from a rule, or a
.owncapability with.owner(...). - Per field:
.access({ read, create, update })on the field. Field rules see only the user, never a record, so the fields a user can read are the same for every row.
defineRoles turns roles into capabilities (workOrders.update.own, money.read, *), and roles.can(...) and roles.is(...) make rules of them; .and() and .or() combine rules.
import { defineRoles, f, resource } from 'protobase/schema'
// Access for the bike shop's work orders: who may do what, which rows they see, and which fields.
/** Roles are bundles of capabilities: `<resource or area>.<action>[.own]`, `*` for everything. */export const roles = defineRoles({ admin: ['*'], desk: ['workOrders.read', 'workOrders.create', 'workOrders.update', 'money.read'], mechanic: ['workOrders.read.own', 'workOrders.update.own'],})
/** shop.work_orders */export const workOrders = resource('workOrders') .table('shop.work_orders') .fields({ id: f.integer().readOnly().filterable().sortable().dbDefault(), assignee: f.text().filterable(), task: f.text(), status: f.enum(['open', 'done']).default('open').filterable(), // Only roles holding `money.read` see the price of the labour; only an admin may change it. labour: f.decimal({ precision: 8, scale: 2 }).access({ read: roles.can('money.read'), update: roles.is('admin') }), }) .primaryKey((r) => r.id) // `.own` capabilities match the rows whose assignee is the signed-in user's id. .owner((r) => r.assignee) .access({ read: roles.can('workOrders.read'), create: roles.can('workOrders.create'), // A finished work order is closed: this rule needs the record, so it is checked against the stored row. update: roles.can('workOrders.update').and((_ctx, record) => (record as { status: string }).status !== 'done'), delete: roles.is('admin'), })Pass the roles to the server (options: { roles } in protobase.config.ts) and every operation without a rule needs its capability (<resource>.<action>). The ERP’s orders use .owner and .search with the ERP’s roles:
import { f, resource } from 'protobase/schema'import { erpAccess } from '../roles'
/** sales.orders. Unique: (organization_id, number) */export const orders = resource('orders') .table('sales.orders') .fields({ id: f.uuid().readOnly().filterable().sortable().dbDefault(), organizationId: f.relation('organizations').filterable().sortable(), number: f.text().filterable().sortable(), companyId: f.relation('companies').filterable().sortable(), personId: f.relation('people').optional().filterable().sortable(), ownerId: f.relation('users').filterable().sortable(), status: f.enum(['draft', 'confirmed', 'picking', 'shipped', 'delivered', 'cancelled']).filterable().sortable(), currencyCode: f.relation('currencies'), discountPercent: f.decimal({ precision: 5, scale: 2 }).default('0'), total: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0').filterable().sortable(), paid: f.boolean().default(false).filterable().sortable(), notes: f.text().optional(), createdAt: f.timestamp().readOnly().filterable().sortable().dbDefault(), updatedAt: f.timestamp().readOnly().dbDefault(), }) .primaryKey((r) => r.id) .tenant((r) => r.organizationId) .owner((r) => r.ownerId) .search((r) => [r.number]) .access(erpAccess('orders'))The UI side of a resource, its names, columns, filters and record page, is in UI config.