Skip to content

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.

docs-examples/bike-shop/data.ts
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(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.

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.

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:

examples/erp/config/products/data.ts
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'))

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:

docs-examples/reference/keys.ts
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.

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 returns true, 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 .own capability 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.

docs-examples/reference/access.ts
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:

examples/erp/config/orders/data.ts
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.