| title | Validation Metadata |
|---|---|
| description | Define data integrity rules — formula conditions, uniqueness, format, state machine transitions, and more |
Validation rules enforce data integrity at the platform level. They run automatically on the write path (insert/update), preventing invalid data from being saved. A validation rule is a deterministic, synchronous, side-effect-free predicate over a single record — it must be decidable from the incoming write (and, on update, the prior record) with no I/O. ObjectStack supports 6 validation types: script, state_machine, format, cross_field, json_schema, and conditional.
- Uniqueness → a unique index (
ObjectSchema.indexes,{ fields, unique: true }, withpartialfor a scoped constraint) or field-levelunique: true. A SELECT-then-INSERT rule is inherently racy (TOCTOU); a DB unique constraint is not. - Async / remote validation → a client-form concern, and an SSRF/latency hazard on the server write path. Keep it in the form layer, or enforce the invariant with a
uniqueindex / lifecycle hook. - Custom handler → a
beforeInsert/beforeUpdatelifecycle hook, the supported extension point for arbitrary validation code.
Validations are defined on an Object's validations array:
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Order = ObjectSchema.create({
name: 'order',
label: 'Order',
fields: {
amount: Field.currency({ label: 'Amount', required: true }),
status: Field.select({
label: 'Status',
options: [
{ label: 'Draft', value: 'draft', default: true },
{ label: 'Submitted', value: 'submitted' },
{ label: 'Approved', value: 'approved' },
{ label: 'Cancelled', value: 'cancelled' },
],
}),
email: Field.email({ label: 'Contact Email' }),
},
validations: [
{
name: 'amount_positive',
type: 'script',
severity: 'error',
message: 'Amount must be greater than zero',
// CEL predicate — TRUE means the record is invalid.
condition: 'record.amount <= 0',
events: ['insert', 'update'],
},
],
});All validation types share these base properties:
| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Unique rule name (snake_case) |
label |
string |
optional | Display label |
description |
string |
optional | Developer documentation |
active |
boolean |
optional | Is the rule active (default: true) |
severity |
enum |
optional | 'error' (blocks save), 'warning', 'info' (default: 'error') |
message |
string |
✅ | User-facing error message |
events |
enum[] |
optional | When to run: 'insert', 'update', 'delete' (default: ['insert', 'update']) |
priority |
number |
optional | Execution order (0-9999, lower runs first, default: 100) |
tags |
string[] |
optional | Categorization tags |
| Severity | Behavior |
|---|---|
error |
Prevents the record from being saved |
warning |
Shows a warning but allows save |
info |
Informational message, no blocking |
Formula-based validation using expressions:
{
name: 'close_date_required',
type: 'script',
severity: 'error',
message: 'Close date is required for closed deals',
condition: "record.status == 'closed_won' && isBlank(record.close_date)",
events: ['insert', 'update'],
}The condition is a CEL predicate and should evaluate to true when the data is invalid. A predicate that cannot be evaluated (parse error, unbound variable) is treated as a broken rule — it is logged and skipped rather than blocking the write.
There is no uniqueness validation type. A SELECT-then-INSERT rule is inherently racy (TOCTOU), so uniqueness is enforced at the data layer instead:
// Field-level uniqueness
email: Field.email({ label: 'Contact Email', unique: true }),
// Composite / scoped uniqueness via ObjectSchema.indexes
indexes: [
{ fields: ['code', 'organization'], unique: true },
// `partial` expresses a scoped/conditional constraint
]Validate against a regex pattern or standard format:
// Regex pattern
{
name: 'phone_format',
type: 'format',
severity: 'error',
message: 'Phone must match format: +1-XXX-XXX-XXXX',
field: 'phone',
regex: '^\\+1-\\d{3}-\\d{3}-\\d{4}$',
events: ['insert', 'update'],
}
// Built-in format
{
name: 'website_format',
type: 'format',
severity: 'error',
message: 'Website must be a valid URL',
field: 'website',
format: 'url',
events: ['insert', 'update'],
}| Property | Type | Description |
|---|---|---|
field |
string |
Target field |
regex |
string |
Custom regex pattern |
format |
enum |
Built-in format: 'email', 'url', 'phone', 'json' |
Enforce allowed state transitions:
{
name: 'order_status_transitions',
type: 'state_machine',
severity: 'error',
message: 'Invalid status transition',
field: 'status',
transitions: {
draft: ['submitted', 'cancelled'],
submitted: ['approved', 'rejected', 'cancelled'],
approved: ['completed'],
rejected: ['draft'],
cancelled: [],
completed: [],
},
events: ['update'],
}| Property | Type | Description |
|---|---|---|
field |
string |
State field name |
transitions |
Record<string, string[]> |
Map of current state → allowed next states |
Validate relationships between multiple fields:
{
name: 'date_range_valid',
type: 'cross_field',
severity: 'error',
message: 'End date must be after start date',
fields: ['start_date', 'end_date'],
condition: 'record.end_date <= record.start_date',
events: ['insert', 'update'],
}Validate JSON data against a JSON Schema:
{
name: 'config_schema',
type: 'json_schema',
severity: 'error',
message: 'Configuration JSON is invalid',
field: 'config',
schema: {
type: 'object',
required: ['host', 'port'],
properties: {
host: { type: 'string' },
port: { type: 'number', minimum: 1, maximum: 65535 },
},
},
events: ['insert', 'update'],
}Apply validation only when a condition is met:
{
name: 'billing_required_for_paid',
type: 'conditional',
message: 'Billing validation',
when: "record.plan_type == 'paid'",
then: {
name: 'billing_address_check',
type: 'script',
severity: 'error',
message: 'Billing address is required for paid plans',
condition: 'isBlank(record.billing_address)',
},
// Optional: a rule to apply when `when` is false
// otherwise: { ... }
}The outer conditional evaluates the when CEL predicate, then recurses into then (when true) or otherwise (when false). The nested rule supplies the violation message; the conditional itself carries the required base message.
When multiple validations exist, priority controls execution order:
// Runs first (lower number = higher priority)
{ name: 'format_check', priority: 10, ... }
// Runs second
{ name: 'uniqueness_check', priority: 50, ... }
// Runs last (default priority)
{ name: 'business_rule', priority: 100, ... }validations: [
// Formula validation
{
name: 'amount_positive',
type: 'script',
severity: 'error',
message: 'Order amount must be positive',
condition: 'record.amount <= 0',
events: ['insert', 'update'],
priority: 10,
},
// State transition
{
name: 'status_flow',
type: 'state_machine',
severity: 'error',
message: 'Invalid status transition',
field: 'status',
transitions: {
draft: ['submitted'],
submitted: ['approved', 'rejected'],
approved: ['shipped'],
rejected: ['draft'],
shipped: ['delivered'],
},
events: ['update'],
priority: 20,
},
// Cross-field
{
name: 'shipping_date_after_order',
type: 'cross_field',
severity: 'error',
message: 'Ship date must be after order date',
fields: ['order_date', 'ship_date'],
condition: 'record.ship_date < record.order_date',
events: ['insert', 'update'],
priority: 40,
},
]
// Order-number uniqueness is enforced with an index, not a validation rule:
// indexes: [{ fields: ['order_number'], unique: true }]- Object Metadata — Objects contain validation rules
- Field Metadata — Field-level constraints (
required,unique,min,max) - Workflow Metadata — Event-driven business automation