Computed fields & hooks

Two ways to put business logic on an EntitySchema: computed fields (values derived from the row) and hooks (logic that runs when rows are created, updated, or deleted). Both live on the schema, so they apply everywhere the schema does - grid, edit form, and generated code.

No-code, in the designer. You don't have to write these by hand. In the visual designer, a field's editor has a ƒ formula box (e.g. qty * price, first + ' ' + last) that compiles into computed, and a Business rules (validation) section builds cross-field rules (price ≥ qty, "required", min/max length, ...) with custom messages that compile into hooks.validate. Bare field names resolve to the row. The sections below are what that generates - and what you'd write directly for anything more complex.

Computed fields

A computed field's value is derived from the row, never stored. Give the field a computed function:

import type { EntitySchema } from '@svgrid/enterprise'

type Order = { id: string; qty: number; price: number; total: number }

const orderSchema: EntitySchema<Order> = {
  name: 'orders',
  idField: 'id',
  fields: [
    { field: 'id', type: 'text', primaryKey: true, readonly: true },
    { field: 'qty', type: 'number', required: true, min: 1 },
    { field: 'price', type: 'number', required: true, min: 0 },
    // Derived from the row - read-only, never submitted.
    { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.price },
  ],
}

A computed field is:

To make the grid show, sort, and filter the value, materialize it onto the rows with withEntityRules (below) or applyComputed(schema, row).

Computed columns sort / filter within the fetched page (they're materialized client-side). For a value the database can sort across all pages, use a real column instead.

Hooks

schema.hooks run business logic at the mutation boundary. Before-hooks can transform the payload or throw to reject; after-hooks are side effects; and validate does cross-field form validation.

const orderSchema: EntitySchema<Order> = {
  name: 'orders',
  idField: 'id',
  fields: [ /* ... */ ],
  hooks: {
    // Transform the create payload (e.g. stamp a timestamp).
    beforeCreate: (values) => ({ ...values, createdAt: new Date().toISOString() }),
    // Cross-field validation: return { field: message } or null.
    validate: (values) => (values.qty <= 0 ? { qty: 'Quantity must be at least 1' } : null),
    // Side effects.
    afterCreate: (row) => console.log('created', row.id),
    afterUpdate: (row) => console.log('updated', row.id),
    beforeDelete: (id) => { /* throw to veto */ },
  },
}

The validate hook runs in two places, so a rule is written once:

Wiring it up: withEntityRules

withEntityRules(source, schema) wraps any ServerDataSource so computed fields are materialized on read and hooks run on writes. It's transparent - pass the result to createServerDataSource (or the grid) exactly as before:

import { withEntityRules, createSqlDataSource } from '@svgrid/enterprise'

const source = withEntityRules(createSqlDataSource(/* ... */), orderSchema)

Optional write methods stay optional, and extra capabilities (getAggregate) pass through. Because every method returns the same computed row shape, the controller's optimistic updates stay correct.

Server-enforced validation at the route

withEntityRules runs your hooks.validate, but the declarative field constraints (required, min / max, minLength / maxLength, pattern, email / url) are data on the schema - and the API route can re-check them on every write, independent of the client. Pass validate: true to createKitHandlers:

import { createKitHandlers } from '@svgrid/enterprise'

export const { POST } = createKitHandlers({
  schema: orderSchema,
  source,
  validate: true, // reject bad writes with 422 { error, fieldErrors }
})

Prefer your own logic? Pass a function instead of true:

validate: ({ action, values, event }) =>
  values.total < 0 ? { total: 'Total cannot be negative' } : null,

Studio generates this for you. Every connected (SQL / Supabase) route the app generator emits already includes validate: true, so a generated app is server-validated out of the box. This pairs with RBAC, which adds the authorize guard on the same route.

A worked example

Computed subtotal / tax / total, a cross-field guard, and a delete veto - all on one schema:

type Invoice = {
  id: string; qty: number; rate: number; taxRate: number
  subtotal: number; tax: number; total: number
  status: 'draft' | 'sent' | 'paid'
}

const invoiceSchema: EntitySchema<Invoice> = {
  name: 'invoices',
  idField: 'id',
  fields: [
    { field: 'id', type: 'text', primaryKey: true, readonly: true },
    { field: 'qty', type: 'number', required: true, min: 1 },
    { field: 'rate', type: 'number', required: true, min: 0, label: 'Rate ($)' },
    { field: 'taxRate', type: 'number', min: 0, max: 1, defaultValue: 0.2 },
    { field: 'subtotal', type: 'number', computed: (r) => r.qty * r.rate },
    { field: 'tax', type: 'number', computed: (r) => r.qty * r.rate * r.taxRate },
    { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.rate * (1 + r.taxRate) },
    { field: 'status', type: 'enum', options: [
      { value: 'draft', label: 'Draft' }, { value: 'sent', label: 'Sent' }, { value: 'paid', label: 'Paid' },
    ] },
  ],
  hooks: {
    // cross-field guard, shown in the form and enforced on write
    validate: (v) => (v.taxRate != null && (v.taxRate < 0 || v.taxRate > 1)
      ? { taxRate: 'Tax rate must be between 0 and 1' } : null),
    // (id, values) - stamp an audit field on update
    beforeUpdate: (id, patch) => ({ ...patch, updatedAt: new Date().toISOString() }),
    // veto a delete by throwing
    beforeDelete: (id) => { /* if (await isPaid(id)) throw new Error('Paid invoices cannot be deleted') */ },
  },
}

Only qty, rate, taxRate, and status are ever submitted - subtotal, tax, and total are derived on every read. Wrap the source with withEntityRules and the exact same rules apply whether the source is in-memory (client-side) or SQL (inside the API route) - write the logic once, run it everywhere.

Live demo: Computed fields & hooks.

See also