
Objectstack Data
- 126 installs
- 18 repo stars
- Updated August 5, 2026
- objectstack-ai/framework
Helps with ai & agent building tasks.
About
objectstack-data is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- objectstack-data
- AI & Agent Building
- AI-coding skill
Objectstack Data by the numbers
- 126 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #3,699 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/objectstack-ai/framework --skill objectstack-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 126 |
|---|---|
| repo stars | ★ 18 |
| Last updated | August 5, 2026 |
| Repository | objectstack-ai/framework ↗ |
What it does
Helps with ai & agent building tasks.
Files
Data Modeling — ObjectStack Data Protocol
Expert instructions for designing business data schemas using the ObjectStack specification. This skill covers Object definitions, Field type selection, relationship modelling, validation rules, index strategy, and lifecycle hooks.
---
Skill Boundaries
| Need | Use instead |
|---|---|
| Query, filter, or aggregate records | objectstack-query |
| Define REST API endpoints or auth | objectstack-api |
| Build views, dashboards, or apps | objectstack-ui |
| Create a plugin or register services | objectstack-platform |
---
When to Use This Skill
- You are creating a new business object (e.g.,
account,project_task) - You need to choose the right field type from the 48 supported types
- You are configuring lookup / master-detail relationships between objects
- You need to add validation rules (uniqueness, cross-field, state machine, etc.)
- You are optimising query performance with indexes
- You are extending an existing object with new fields or capabilities
- You need to implement data lifecycle hooks for business logic
---
Core Concepts
Object Definition
An Object is the fundamental data entity in ObjectStack. It maps to a database table and exposes automatic CRUD APIs.
Required properties:
| Property | Type | Convention | Description |
|---|---|---|---|
name | string | snake_case | Immutable machine identifier (/^[a-z_][a-z0-9_]*$/) |
fields | map | keys in snake_case | Field definitions |
Important optional properties:
| Property | Default | Description |
|---|---|---|
label | Auto from name | Human-readable singular label |
pluralLabel | — | Plural form (e.g., "Accounts") |
namespace | — | Deprecated — ignored by the runtime. Embed prefix directly in name instead (e.g. name: 'crm_account') |
datasource | 'default' | Target datasource ID for virtualized data |
displayNameField | 'name' | Field used as record display name |
enable | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) |
fieldGroups | — | Ordered list of logical field groups for forms/detail pages (see Field Groups) |
Object Capabilities (enable)
Toggle system behaviours per object:
| Flag | Default | Purpose |
|---|---|---|
trackHistory | false | Field-level audit trail |
searchable | true | Index records for global search |
apiEnabled | true | Expose via automatic REST / GraphQL APIs |
apiMethods | all | Whitelist specific operations (get, list, create, …) |
files | false | Attachments & document management |
feeds | false | Social feed, comments, mentions |
activities | false | Tasks & events tracking |
trash | true | Soft-delete with restore |
mru | true | Most Recently Used tracking |
clone | true | Record deep cloning |
---
Field Groups (MVP)
Organize fields into logical groups (e.g., "Contact Information", "Billing", "System") for forms, detail pages, and editors.
- Declare groups on
ObjectSchema.fieldGroups— array order is the display order. - Assign each field to a group via
Field.group, which references an
ObjectFieldGroup.key. In-group display order equals the traversal order of fields.
- Group keys must be
snake_case; group labels are human-readable.
import { ObjectSchema } from '@objectstack/spec';
export default ObjectSchema.create({
name: 'account',
label: 'Account',
fieldGroups: [
{ key: 'contact_info', label: 'Contact Information', icon: 'user' },
{ key: 'billing', label: 'Billing', defaultExpanded: false },
{ key: 'system', label: 'System', visibleOn: P`os.user.isAdmin == true` },
],
fields: {
name: { type: 'text', required: true, group: 'contact_info' },
email: { type: 'email', group: 'contact_info' },
phone: { type: 'phone', group: 'contact_info' },
vat_id: { type: 'text', group: 'billing' },
billing_address: { type: 'address', group: 'billing' },
created_at: { type: 'datetime', readonly: true, group: 'system' },
created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
},
});Supported migrations at this layer: add / rename / delete / reorder groups (edit the fieldGroups array), assign a field to a group (edit Field.group). Explicit per-field in-group ordering is deferred to a future iteration.
---
Conditional Field Rules
Put conditional UI/data-entry rules on the field definition when the rule belongs to the data model and should apply everywhere the field is edited: default forms, Studio-authored forms, inline master-detail grids, public forms, and API-backed writes.
import { P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Invoice = ObjectSchema.create({
name: 'invoice',
fields: {
status: Field.select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
],
}),
paid_at: Field.datetime({
visibleWhen: P`record.status == 'paid'`,
requiredWhen: P`record.status == 'paid'`,
}),
locked_total: Field.currency({
readonlyWhen: P`record.status == 'paid'`,
}),
},
});- Use
visibleWhento hide irrelevant fields in ObjectUI forms. - Use
readonlyWhenfor state-locked fields; the ObjectQL write path ignores
incoming changes when the predicate is TRUE.
- Use
requiredWhenfor conditional requiredness; the ObjectQL validator
enforces it on submit. conditionalRequired is a deprecated compatibility alias, not the preferred authoring field.
- For inline
master_detailgrids, predicates are evaluated row-by-row against
the child row's record, so line-item rules should live on child fields.
- For complex predicates, load objectstack-formula and emit CEL via
P\...\`; do not use Salesforce-style AND, IN (...), or {field}` syntax.
---
Quick Reference — Detailed Rules
For comprehensive documentation with incorrect/correct examples:
- [Naming Conventions](./rules/naming.md) — snake_case rules, option values, config properties
- [Field Types](./rules/field-types.md) — All 48 field types with decision tree and configs
- [Relationships](./rules/relationships.md) — lookup vs master_detail, junction patterns, delete behaviors
- [Validation Rules](./rules/validation.md) — All validation types, script inversion, severity levels
- [Index Strategy](./rules/indexing.md) — btree/gin/gist/fulltext, composite indexes, partial indexes
- [Lifecycle Hooks](./rules/hooks.md) — Hook quick reference (→ see references/data-hooks.md for the full 14-event guide)
- [Datasources & Federation](./rules/datasources.md) —
defineDatasource, external/federated objects (remoteName/columnMap), auto-connect gating, credentials; ❌ nofield.columnNameon external objects
---
Quick-Start Template
import { ObjectSchema } from '@objectstack/spec';
export default ObjectSchema.create({
name: 'support_case',
label: 'Support Case',
enable: {
trackHistory: true,
feeds: true,
activities: true,
trash: true,
},
fields: {
subject: { type: 'text', required: true, maxLength: 255 },
description: { type: 'richtext' },
status: { type: 'select', required: true, options: [
{ label: 'New', value: 'new', default: true },
{ label: 'Open', value: 'open' },
{ label: 'Escalated', value: 'escalated', color: '#e74c3c' },
{ label: 'Resolved', value: 'resolved', color: '#2ecc71' },
{ label: 'Closed', value: 'closed' },
]},
priority: { type: 'select', options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium', default: true },
{ label: 'High', value: 'high', color: '#e67e22' },
{ label: 'Urgent', value: 'urgent', color: '#e74c3c' },
]},
account: { type: 'lookup', reference: 'account', required: true },
contact: { type: 'lookup', reference: 'contact' },
assigned_to: { type: 'lookup', reference: 'user' },
due_date: { type: 'datetime' },
},
validations: [
{
name: 'status_flow',
type: 'state_machine',
field: 'status',
transitions: {
new: ['open'],
open: ['escalated', 'resolved'],
escalated: ['open', 'resolved'],
resolved: ['open', 'closed'],
closed: [],
},
message: 'Invalid status transition.',
},
],
indexes: [
{ fields: ['status', 'priority'] },
{ fields: ['account'] },
],
});---
Schema evolution on an existing database
The metadata→DB sync is additive-only: new tables/columns are created on boot, but existing columns are never altered or dropped. A non-additive change to an object that already has data silently diverges from the physical schema, and the database column wins at write time (#2186):
| Change | Existing DB on restart |
|---|---|
| add object / field / index | ✅ applied automatically (additive) |
required: true → false (relax NOT NULL) | dev auto-heals (autoMigrate:'safe'); otherwise os migrate apply |
| type / length change, drop field, rename | os migrate apply (--allow-destructive for drops / tightenings) |
Tell-tale: /meta reports a field optional but a write still 400s "<field> is required" — that is a stale NOT NULL column (physical drift), not a validator bug. os dev reconciles loosening automatically; otherwise os migrate plan to preview and os migrate apply to reconcile. CLI details: see objectstack-platform.
---
Common Patterns
Naming Rules Summary
| Context | Convention | Example |
|---|---|---|
Object name | snake_case | project_task |
| Field keys | snake_case | first_name, due_date |
| Schema properties | camelCase | maxLength, referenceFilters |
Option value | lowercase | in_progress |
See rules/naming.md for incorrect/correct examples.
Field Type Selection
48 types available. Quick categories:
- Text:
text,textarea,email,url,phone,markdown,html,richtext - Numbers:
number,currency,percent - Date/Time:
date,datetime,time - Logic:
boolean,toggle - Selection:
select,multiselect,radio,checkboxes - Relational:
lookup,master_detail,tree - Media:
image,file,avatar,video,audio - Calculated:
formula,summary,autonumber—formulafields take a CEL expression informula(useF\...\`from@objectstack/spec`); see objectstack-formula skill - Enhanced:
location,address,code,json,color,rating,slider,signature,qrcode,progress,tags,vector
See rules/field-types.md for full reference.
Relationship Patterns
| Pattern | Implementation |
|---|---|
| One-to-Many (independent) | lookup field on child |
| One-to-Many (owned) | master_detail field on child |
| Many-to-Many (simple) | multi-value lookup (multiple: true) — an array column of ids |
| Many-to-Many (with attributes) | Junction object with two lookup fields |
| Hierarchical | tree field (self-reference) |
See rules/relationships.md for detailed examples.
`multiple: true` lookup ≠ junction object. A multi-value lookup
({ type: 'lookup', reference: 'x', multiple: true }) is stored and read as anarray of ids on the record — reference elements positionally
({record.tags.0} in flow values). It is NOT a junction table. Reach for ajunction object (two lookups) only when the relationship itself carries
attributes (role, added_at, …). (#1872)
Validation Patterns
⚠️ Script validation is inverted: Validation fails when expression is true.
On insert, an optional field omitted from the payload reads as null in avalidation predicate — so record.due_date == null matches an omitted field thesame as an explicit null (#1871). (On update, the prior record supplies it.)Common validation types:
script— Formula expression (inverted logic)unique— Composite uniquenessstate_machine— Legal state transitionsformat— Regex or built-in formatcross_field— Compare values across fields
See rules/validation.md for all types and examples.
Index Patterns
Omit default values: type defaults to 'btree', unique defaults to false.
indexes: [
{ fields: ['status', 'created_at'] }, // btree (default)
{ fields: ['email'], unique: true }, // btree + unique
{ fields: ['description'], type: 'fulltext' }, // non-default type
]See rules/indexing.md for composite/partial/gin/gist indexes.
Lifecycle Hooks
Implement business logic at data operation lifecycle points:
import { Hook, HookContext } from '@objectstack/spec/data';
const accountHook: Hook = {
name: 'account_defaults',
object: 'account',
events: ['beforeInsert'],
handler: async (ctx: HookContext) => {
if (!ctx.input.industry) {
ctx.input.industry = 'Other';
}
ctx.input.created_at = new Date().toISOString();
},
};
export default accountHook;See rules/hooks.md for the quick reference, or references/data-hooks.md for complete documentation of all 14 lifecycle events, registration modes, and patterns.
---
CRM Schema Blueprint (Production Pattern)
Mirror these CRM-style patterns when designing enterprise metadata objects:
| Pattern | Typical Location | Implementation Cue |
|---|---|---|
| Object layout via field groups | src/objects/*.object.ts | Use fieldGroups[] + per-field group for deterministic form structure |
| Capability gating | src/objects/*.object.ts | Use enable flags (trackHistory, apiMethods, files, feeds, activities) per object |
| Index + validation pairing | src/objects/*.object.ts | Keep indexes[] aligned to common filters and enforce invariants with validations[] |
| Relationship constraints | src/objects/*.object.ts | Use lookup + referenceFilters for constrained child selection |
| Lifecycle automation | src/objects/*.hook.ts | Use a lifecycle hook (defineHook()) or a top-level record_change flow for field updates triggered by record changes. There is no object-level workflows[] field — authoring one is a build error (#1535). |
| State transitions | src/objects/*.state.ts | Prefer explicit stateMachines for lifecycle-heavy objects |
For metadata authoring, keep expressions in CEL (P\...\`, F\...\, cel\...\`) and avoid legacy formula-string syntax.
---
Object Extension Model
When extending an object you do not own:
{
ownership: 'extend',
extend: 'crm.account', // target object FQN
fields: { custom_score: { type: 'number' } },
priority: 300, // higher = applied later
}prioritycontrols merge order (default200; range0–999)- Extensions can add fields, validations, and indexes — but cannot remove them
---
Security & Access Control
Per-object access control is part of the schema, not a separate layer. Configure these alongside fields / validations / hooks:
Object-level permissions (RBAC)
Bind CRUD operations to roles:
permissions: {
read: ['authenticated'],
create: ['sales', 'admin'],
update: ['record_owner', 'sales_manager', 'admin'],
delete: ['admin'],
}- Source:
node_modules/@objectstack/spec/src/security/permission.zod.ts - Combine with
enable.apiMethodsto also restrict the HTTP surface.
Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis
For owner-scoped (private) objects, a per-object grant on a permission set can carry `readScope` / `writeScope` that widens the owner-match declaratively — the ERP "my own / my reports / my unit / my unit and below / whole org" axis (ADR-0057 D1). It saves hand-writing one RLS policy per object.
// in a permission set's `objects` map
objects: {
account: {
allowRead: true, allowEdit: true,
readScope: 'unit_and_below', // see accounts owned by my BU + descendant BUs
writeScope: 'own', // but only edit my own
},
}| Scope | Who you can see / write |
|---|---|
own | owner == me (baseline; unset = this) |
own_and_reports | me + everyone below me on the sys_user.manager_id chain |
unit | owners in my business unit (sys_business_unit) |
unit_and_below | my BU + all descendant BUs (BFS) |
org | the whole tenant (≈ viewAllRecords / modifyAllRecords) |
Resolves at request time into an owner_id IN (…) set and AND-injects like RLS (no compiler change; ADR-0055). Sharing rules still widen on top.
⚠️ Open-core boundary (ADR-0016).ownandorgwork in open-source. The
hierarchy-relative scopes —own_and_reports/unit/unit_and_below—
need the paid @objectstack/security-enterprise plugin (BU-subtree +manager-chain resolver). Without it they fail closed to `own` (never
fail-open), and defineStack errors if a grant uses one withoutrequires: ['hierarchy-security']. In an open-source app, authorown/org
+ explicit sharing rules; reach for unit* only when the enterprise plugin ispresent.
Row-Level Security (RLS)
The enforced RLS surface is a list of rowLevelSecurity policies on a permission set / profile (PermissionSetSchema.rowLevelSecurity), not a CEL predicate on the object. Each policy carries a using (read filter) and/or check (write filter) string predicate. The compiler ANDs using into every read for users carrying that set; check gates writes. (@objectstack/plugin-security re-reads the target row through the write filter before single-id update/delete.)
// in a *.profile.ts / permission-set
rowLevelSecurity: [
{
name: 'own_records',
operations: ['select', 'update', 'delete'],
using: 'owner_id == current_user.id', // read scope
check: 'owner_id == current_user.id', // write scope
},
{
name: 'org_isolation',
operations: ['all'],
using: 'organization_id == current_user.organization_id',
},
]Predicates are canonical CEL (ADR-0058): field == current_user.<prop>, field == 'literal', field in current_user.<array>, comparisons (>/</>=/<=), &&/||/!, and == null checks all lower to a pushdown filter. No cross-object traversal or subqueries — those are a compile error (ADR-0055), never silently dropped. A legacy SQL-style = / IN (...) predicate still compiles via a deprecated bridge (emits a warning) but should be authored in CEL. The compiler resolves these current_user.* placeholders:
| Placeholder | Resolves to |
|---|---|
current_user.id | the caller's user id (ownership) |
current_user.email | the caller's email (ADR-0056 #2054) |
current_user.organization_id | the caller's tenant |
current_user.org_user_ids | ids of users in the same org (for IN) |
current_user.roles | the caller's roles (for IN) |
- Source:
node_modules/@objectstack/spec/src/security/permission.zod.ts(policy shape),
node_modules/@objectstack/spec/src/security/rls.zod.ts (predicate grammar).
- Owner-scoping shortcut: the built-in
member_defaultset already owner-scopes
writes via owner_only_writes / owner_only_deletes, and an object's sharingModel (private / public_read / controlled_by_parent, ADR-0056 D1) is the declarative way to set the org-wide default — prefer those over hand-written policies for the common cases.
Experimental: a separate object-level rls config with a free-form CELpredicateexists inrls.zod.tsbut is marked experimental (ADR-0056 D8) and
is not the path the runtime compiles/enforces. Author RLS as
rowLevelSecurity policies as shown above.Field-level encryption
Encrypt sensitive columns at rest. Decryption is automatic for callers with permission; raw bytes are stored otherwise.
fields: {
ssn: {
type: 'text',
encryptionConfig: { algorithm: 'aes-256-gcm', keyRef: 'pii_key_v1' },
},
}- Source:
node_modules/@objectstack/spec/src/system/encryption.zod.ts - Key rotation: bump
keyRefand let the migration re-encrypt.
PII masking
Show partial values (****-****-1234) to roles that can read but should not see the full value. Applied after RLS, before serialization.
fields: {
credit_card: {
type: 'text',
maskingRule: {
pattern: 'last4', // built-in: last4 | first2 | email | custom
visibleToRoles: ['billing_admin'],
},
},
}- Source:
node_modules/@objectstack/spec/src/system/masking.zod.ts
Multi-tenancy
For SaaS, set tenancy on the object schema. Combined with RLS, this enforces per-tenant data isolation:
| Mode | Storage | When to use |
|---|---|---|
shared | Single table, tenant_id column + RLS | Default — most cost-efficient |
isolated | Separate database per tenant | Regulatory isolation / large tenants |
hybrid | Shared schema, tenant-specific sharding | High-volume multi-tenant |
Cross-skill notes
- API auth providers (OIDC, JWT, API key) live in objectstack-api.
- Kernel-level RBAC services (role inheritance, custom policy engines)
live in objectstack-platform.
- CEL predicate syntax (
P\...\``, operators, functions) lives in
objectstack-formula.
---
Metadata Protection (protection)
Package authors can lock shipped metadata against Studio edits / overlays / deletes. See ADR-0010 for the full model.
The protection block is declared on the source schema (*.object.ts, *.app.ts, *.view.ts, …) and stripped at load time — it never appears in the runtime envelope. The runtime instead populates _lock, _lockReason, _lockDocsUrl, _lockSource, and _packageId, which REST returns to Studio and the lock banner reads.
Schema
protection?: {
/** Lock level — controls what Studio can do to this item. */
lock: 'none' | 'no-overlay' | 'no-delete' | 'full';
/** Human-readable reason shown in the Studio lock banner. */
reason?: string;
/** Optional doc URL — renders as "查看文档 →" link in the banner. */
docsUrl?: string;
}lock | Edit (overlay) | Delete | Typical use |
|---|---|---|---|
none (default) | ✅ | ✅ | Normal authored metadata |
no-overlay | ❌ | ✅ | Schema is platform-defined but tenant can drop it (e.g. sys_role) |
no-delete | ✅ | ❌ | Tenant may customize fields but the object itself must exist |
full | ❌ | ❌ | Core admin UI / platform identity (e.g. sys_user, app/setup) |
Example — fully locked platform object
// packages/platform-objects/src/identity/sys-user.object.ts
import { defineObject } from '@objectstack/spec';
export const SysUserObject = defineObject({
name: 'sys_user',
label: 'User',
protection: {
lock: 'full',
reason: 'Core identity object — see ADR-0010.',
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
},
fields: [ /* ... */ ],
});Example — schema-locked but deletable
// packages/platform-objects/src/security/sys-role.object.ts
export const SysRoleObject = defineObject({
name: 'sys_role',
label: 'Role',
protection: {
lock: 'no-overlay',
reason: 'RBAC schema is platform-defined — see ADR-0010.',
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
},
fields: [ /* ... */ ],
});Example — locking a shipped app
The same block works on non-object metadata (apps, views, dashboards, flows, agents, tools, skills, reports, email-templates):
// packages/plugin-auth/src/apps/setup.app.ts
import { defineApp } from '@objectstack/spec';
export const SetupApp = defineApp({
name: 'setup',
label: 'Setup',
protection: {
lock: 'full',
reason: 'Core admin UI shipped by @objectstack/platform-objects — see ADR-0010.',
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
},
// ...
});Enforcement
- REST:
PUT /api/v1/meta/:type/:nameandDELETEreturn403 item_locked
for any operation the lock forbids. Layered-read endpoints (GET ?layers=true) include lock, lockReason, lockDocsUrl, lockSource, and packageId so Studio can render the banner.
- Studio:
ResourceEditPagerenders a banner with the lock reason and the
"查看文档 →" link; edit + delete buttons are hidden according to the lock.
- Package vs Artifact source:
_lockSource: 'package'when the lock comes
from a code-shipped schema, 'artifact' when set by a workspace artifact. Artifact locks override package locks (workspace wins).
Authoring guidance
- Default to no `protection` block for tenant-authored metadata.
- Use
fullfor anything Studio editing would break at runtime (core identity,
platform admin UIs, system flows).
- Use
no-overlayfor schemas that platform owns but a tenant may legitimately
not need (then they can delete it).
- Always include
reason— it is the only thing the end-user sees first. - Prefer pointing
docsUrlto an ADR or onboarding doc, not a marketing page.
---
Advanced Features Checklist
| Feature | When to Consider |
|---|---|
tenancy | Multi-tenant SaaS — choose shared, isolated, or hybrid |
softDelete | Regulatory requirement for data retention |
versioning | Audit / compliance — snapshot, delta, or event-sourcing |
partitioning | Tables > 100M rows — range, hash, or list |
cdc | Real-time sync to Kafka, webhooks, or data lakes |
encryptionConfig | GDPR / HIPAA / PCI-DSS field-level encryption |
maskingRule | PII masking for non-privileged users |
---
Seed Data & Fixtures (defineDataset())
Object definition and its seed data live together — writing a *.object.ts almost always goes with a *.seed.ts (test fixtures, reference rows, bootstrap data). defineDataset() is type-safe: pass the object definition and TypeScript checks every record's field keys at compile time.
Quick start
// src/data/index.ts
import { defineDataset } from '@objectstack/spec/data';
import { Status } from '../objects/status.object';
import { Category } from '../objects/category.object';
// Reference data — every environment
export const statusSeed = defineDataset(Status, {
externalId: 'code',
mode: 'upsert',
records: [
{ code: 'active', label: 'Active', color: '#2ecc71' },
{ code: 'inactive', label: 'Inactive', color: '#95a5a6' },
],
});
// Demo data — dev/test only
export const categorySeed = defineDataset(Category, {
externalId: 'slug',
mode: 'upsert',
env: ['dev', 'test'],
records: [
{ slug: 'electronics', name: 'Electronics' },
],
});
export const SeedData = [statusSeed, categorySeed]; // parents firstDataset fields
| Field | Default | Purpose |
|---|---|---|
object | derived | Auto-set from objectDef.name — never write manually |
externalId | 'name' | Stable business key used for upsert / update lookup |
mode | 'upsert' | Import strategy (see below) |
env | ['prod','dev','test'] | Environments where the dataset loads |
records | — | Partial<Record<keyof object.fields, unknown>>[] |
Full Zod shape: node_modules/@objectstack/spec/src/data/dataset.zod.ts.
Import modes
| Mode | Behavior | Use for |
|---|---|---|
upsert (default) | Update by externalId, insert if missing. Idempotent. | Reference data, bootstrap rows |
insert | Insert all; fail on duplicate externalId. | Append-only / audit tables |
update | Update only existing rows; never create. | Patching existing config |
ignore | Insert; silently skip duplicates. | Additive bootstrap |
replace ⚠️ | Delete everything, then insert. Data loss. | Cache / lookup tables only — never user data |
externalId selection
Pick a stable natural business key. Never use `id` — UUIDs differ across environments.
| Scenario | Key |
|---|---|
| Named entities (country, currency) | 'code' / 'slug' |
| Users / contacts | 'email' |
| Externally sourced | 'external_id' |
| Generic | 'name' (default) |
Relationship references
For lookup fields, supply the natural key of the target record (not its UUID). The seed runner resolves at load time. Order datasets so parents appear before children in the exported array:
If a lookup value matches no natural key, the loader now falls back to
resolving it as the target's id (#1814) — so a reference to a real existingrecord by internal id resolves instead of dangling to null. Natural keys
remain the portable default; rely on the id fallback only for records you
didn't seed (e.g. a system user).
const contacts = defineDataset(Contact, {
externalId: 'email',
records: [{
email: 'john@acme.example.com',
first_name: 'John',
account: 'Acme Corporation', // natural key of an Account record
}],
});Dynamic values (CEL)
Any field value may be a CEL expression evaluated at install time against a single per-load pinned now. This is the only correct way to author time-based or identity-derived seed values — new Date() ships the package author's clock to every customer and breaks build determinism.
import { defineDataset, cel } from '@objectstack/spec';
defineDataset(Opportunity, {
records: [{
name: 'Acme Q3 Renewal',
close_date: cel`daysFromNow(45)`,
created_at: cel`now()`,
owner_id: cel`os.user.id`, // installer
organization_id: cel`os.org.id`,
}],
});Stdlib in seed context: now(), today(), daysFromNow(n), daysAgo(n), isBlank(v), coalesce(v, fallback). Scope: os.user, os.org, os.env. See objectstack-formula for the full contract.
Determinism gate: two consecutive os build runs with no source changes must produce byte-identical dist/objectstack.json. CEL + pinned now is what guarantees that — using Date.now() will fail CI.
Seed best practices
| Practice | Why |
|---|---|
Always use defineDataset(), never DatasetSchema.parse() | Lose compile-time field checking otherwise |
Prefer natural keys (code / email / slug) | Portable across environments |
Default to upsert | Idempotent re-runs |
Scope demo data with env: ['dev','test'] | Keep noise out of prod |
| Order datasets parent → child in the exported array | References resolve at load time |
Use replace only on cache/lookup tables, with comments | Data-loss footgun |
One {object}.seed.ts file per object | Readability at scale |
---
Linting & Generation Quality
objectstack lint checks the data model against the conventions in this skill — not just naming/labels but the relationship/master-detail/roll-up patterns. Run it after authoring or generating metadata. Severities: error (structural, fails the command), warning (likely-wrong choice), suggestion (nudge).
Data-model rules (in addition to naming/label/i18n):
| Rule | Severity | Catches |
|---|---|---|
relationship/missing-reference | error | lookup/master_detail without a reference target |
relationship/master-detail-required | warning | a master_detail that isn't required (a detail can't exist without its master) |
relationship/delete-behavior | suggestion | master_detail without an explicit deleteBehavior |
relationship/line-items-inline-edit | suggestion | a *_line/*_item master_detail child without inlineEdit |
relationship/line-item-should-be-master-detail | suggestion | a line-item-shaped child using lookup instead of master_detail |
relationship/association-inline-edit | warning | an association (comment/audit/activity) marked inlineEdit (clutters the parent form — use a detail-page related list) |
rollup/missing-summary | suggestion | a parent of numeric master_detail children with no roll-up summary |
field/select-missing-options | warning | a select/multiselect/radio with no options (or options source) |
object/missing-name-field | suggestion | an object with no name/title field or primaryField |
These same rules are the rubric for AI-generated metadata — a generation is "good" exactly when it is schema-valid and lint-clean:
objectstack lint --score— print a 0–100 metadata-quality score (+ letter
grade and severity breakdown) for the current project. Schema errors and lint errors weigh most; suggestions barely move it.
objectstack lint --eval— run the generation eval over a bundled golden
corpus (invoice+lines, project+tasks, blog+comments, expense+lines, account+contacts) offline; each case must clear the pass bar (--eval-min, default 75). Deterministic, no API key.
objectstack lint --eval --generator ./gen.mjs— live eval: the module
default-exports (prompt, id) => stack; wire it to your agent / AIService.generateObject<SolutionBlueprint> (+ blueprint→metadata expansion) to benchmark a real model against the same rubric.
When generating object metadata, target a lint-clean model: master_detail (with required + deleteBehavior + inlineEdit for line items), roll-up summaries on parents, select options, and a name/title field per object.
---
Verify your work
After authoring or editing any *.object.ts / *.seed.ts, run the author-time gate before reporting done:
os validate # Zod schema + CEL predicates (record.<field> existence) + bindings
# or: os build # the same gates, plus emits dist/It catches what otherwise fails silently at runtime: a bare field ref in a requiredWhen / readonlyWhen / visibleWhen, a validation rule, a formula, or a row-level-security/sharing predicate (done instead of record.done) that evaluates to null and never fires (#2183/#2185). os lint is a separate pass that additionally checks the data model against the conventions in this skill (relationships, master-detail, roll-ups) — run it too, but it does not replace os validate. (Reminder: two consecutive os build runs with no source change must be byte-identical — see the determinism gate above.) In a scaffolded project the gate is npm run validate.
---
References
See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.
Evaluation Tests (evals/)
This directory is reserved for future skill evaluation tests.
Purpose
Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance.
Structure
When implemented, evals will follow this structure:
evals/
├── naming/
│ ├── test-object-names.md
│ ├── test-field-keys.md
│ └── test-option-values.md
├── relationships/
│ ├── test-lookup-vs-master-detail.md
│ └── test-junction-patterns.md
├── validation/
│ ├── test-script-inversion.md
│ └── test-state-machine.md
└── ...Format
Each eval file will contain: 1. Scenario — Description of the task 2. Expected Output — Correct implementation 3. Common Mistakes — Incorrect patterns to avoid 4. Validation Criteria — How to score the output
Status
⚠️ Not yet implemented — This is a placeholder for future development.
Contributing
When adding evals: 1. Each eval should test a single, specific rule or pattern 2. Include both positive (correct) and negative (incorrect) examples 3. Reference the corresponding rule file in rules/ 4. Use realistic scenarios from actual ObjectStack projects
objectstack-data — Schema References
Auto-generated by packages/spec/scripts/build-skill-references.ts.Do not edit — re-run pnpm --filter @objectstack/spec run gen:skill-refs to update.Schemas live in the published @objectstack/spec package. Read them directly from node_modules — there is no local copy in the skill bundle.
Core schemas
node_modules/@objectstack/spec/src/data/dataset.zod.ts— Data Import Strategynode_modules/@objectstack/spec/src/data/datasource.zod.ts— Driver Identifiernode_modules/@objectstack/spec/src/data/field.zod.ts— Field Type Enumnode_modules/@objectstack/spec/src/data/hook.zod.ts— Hook Lifecycle Eventsnode_modules/@objectstack/spec/src/data/object.zod.ts— API Operations Enumnode_modules/@objectstack/spec/src/data/validation.zod.ts— ObjectStack Validation Protocolnode_modules/@objectstack/spec/src/security/permission.zod.ts— Entity (Object) Level Permissions
Transitive dependencies
node_modules/@objectstack/spec/src/automation/state-machine.zod.ts— XState-inspired State Machine Protocolnode_modules/@objectstack/spec/src/data/hook-body.zod.ts— Capability tokens a script body may request.node_modules/@objectstack/spec/src/security/rls.zod.ts— Row-Level Security (RLS) Protocolnode_modules/@objectstack/spec/src/shared/expression.zod.ts— Expression Protocolnode_modules/@objectstack/spec/src/shared/http.zod.ts— Shared HTTP Schemasnode_modules/@objectstack/spec/src/shared/identifiers.zod.ts— System Identifier Schemanode_modules/@objectstack/spec/src/shared/lazy-schema.ts— Wrap a Zod schema constructor so its body is only evaluated on first use.node_modules/@objectstack/spec/src/system/encryption.zod.ts— Field-level encryption protocolnode_modules/@objectstack/spec/src/system/masking.zod.ts— Data masking protocol for PII protectionnode_modules/@objectstack/spec/src/ui/action.zod.ts— Action Parameter Schemanode_modules/@objectstack/spec/src/ui/i18n.zod.ts— I18n Object Schemanode_modules/@objectstack/spec/src/ui/responsive.zod.ts— Breakpoint Name Enumnode_modules/@objectstack/spec/src/ui/sharing.zod.ts— Sharing & Embedding Protocolnode_modules/@objectstack/spec/src/ui/view.zod.ts— HTTP Method Enum & HTTP Request Schema
How to read these
1. The schemas are runtime Zod definitions. Use Read on the absolute path under node_modules/@objectstack/spec/src/ to inspect field shapes, .describe() text, enums, and refinements. 2. TypeScript types: import type { … } from '@objectstack/spec' (or the matching subpath export). 3. Runtime values: import { … } from '@objectstack/spec' — the package re-exports every schema and helper.
Data Lifecycle Hooks — Reference
Reference companion to objectstack-data/SKILL.md. Comprehensive guide to the 14 data lifecycle events, registration modes, the HookContext API, and common patterns (validation, defaults, audit logging, workflows).
Writing Hooks — ObjectStack Data Lifecycle
Expert instructions for third-party developers to write data lifecycle hooks in ObjectStack. Hooks are the primary extension point for adding custom business logic, validation rules, side effects, and data transformations to CRUD operations.
---
When to Use This Skill
- You need to add custom validation beyond declarative rules.
- You want to enrich data (set defaults, calculate fields, normalize values).
- You need to trigger side effects (send emails, update external systems, publish events).
- You want to enforce business rules that span multiple fields or objects.
- You need to transform data before or after database operations.
- You want to integrate with external APIs during data operations.
- You need to implement audit trails or compliance requirements.
---
Core Concepts
What Are Hooks?
Hooks are event handlers that execute during the ObjectQL data access lifecycle. They intercept operations at specific points (before/after) and can:
- Read the operation context (user, session, input data)
- Modify input parameters or operation results
- Validate data and throw errors to abort operations
- Trigger side effects (notifications, integrations, logging)
Hook Lifecycle Events
ObjectStack provides 14 lifecycle events organized by operation type:
| Event | When It Fires | Use Cases |
|---|---|---|
| Read Operations | ||
beforeFind | Before querying multiple records | Filter queries by user context, log access |
afterFind | After querying multiple records | Transform results, mask sensitive data |
beforeFindOne | Before fetching a single record | Validate permissions, log access |
afterFindOne | After fetching a single record | Enrich data, mask fields |
beforeCount | Before counting records | Filter by context |
afterCount | After counting records | Log metrics |
beforeAggregate | Before aggregate operations | Validate aggregation rules |
afterAggregate | After aggregate operations | Transform results |
| Write Operations | ||
beforeInsert | Before creating a record | Set defaults, validate, normalize |
afterInsert | After creating a record | Send notifications, create related records |
beforeUpdate | Before updating a record | Validate changes, check permissions |
afterUpdate | After updating a record | Trigger workflows, sync external systems |
beforeDelete | Before deleting a record | Check dependencies, prevent deletion |
afterDelete | After deleting a record | Clean up related data, notify users |
Before vs After Hooks
| Aspect | before* Hooks | after* Hooks |
|---|---|---|
| Purpose | Validation, enrichment, transformation | Side effects, notifications, logging |
| Can modify | ctx.input (mutable) | ctx.result (mutable) |
| Can abort | Yes (throw error → rollback) | No (operation already committed) |
| Transaction | Within transaction | After transaction (unless async: false) |
| Error handling | Aborts operation by default | Logged by default (configurable) |
---
Hook Definition Schema
Every hook must conform to the HookSchema:
import { Hook, HookContext } from '@objectstack/spec/data';
const myHook: Hook = {
// Required: Unique identifier (snake_case)
name: 'my_validation_hook',
// Required: Target object(s)
object: 'account', // string | string[] | '*'
// Required: Events to subscribe to
events: ['beforeInsert', 'beforeUpdate'],
// Required: Handler function (inline or string reference)
handler: async (ctx: HookContext) => {
// Your logic here
},
// Optional: Execution priority (lower runs first)
priority: 100, // System: 0-99, App: 100-999, User: 1000+
// Optional: Run in background (after* events only)
async: false,
// Optional: Conditional execution
condition: "status = 'active' AND amount > 1000",
// Optional: Human-readable description
description: 'Validates account data before save',
// Optional: Error handling strategy
onError: 'abort', // 'abort' | 'log'
// Optional: Execution timeout (ms)
timeout: 5000,
// Optional: Retry policy
retryPolicy: {
maxRetries: 3,
backoffMs: 1000,
},
};Key Properties Explained
object — Target Scope
// Single object
object: 'account'
// Multiple objects
object: ['account', 'contact', 'lead']
// All objects (use sparingly — performance impact)
object: '*'events — Lifecycle Events
// Single event
events: ['beforeInsert']
// Multiple events (common pattern)
events: ['beforeInsert', 'beforeUpdate']
// After events for side effects
events: ['afterInsert', 'afterUpdate', 'afterDelete']handler — Implementation
Handlers can be:
1. Inline functions (recommended for simple hooks):
handler: async (ctx: HookContext) => {
if (!ctx.input.email) {
throw new Error('Email is required');
}
}2. String references (for registered handlers):
handler: 'my_plugin.validateAccount'priority — Execution Order
Lower numbers execute first:
// System hooks (framework internals)
priority: 50
// Application hooks (your app logic)
priority: 100 // default
// User customizations
priority: 1000async — Background Execution
Only applicable for after* events:
// Blocking (default) — runs within transaction
async: false
// Fire-and-forget — runs in background
async: trueWhen to use async: true:
- Sending emails/notifications
- Calling slow external APIs
- Logging to external systems
- Non-critical side effects
When to use async: false:
- Creating related records
- Updating dependent data
- Critical consistency requirements
condition — Declarative Filtering
Skip handler execution if condition is false:
// Only run for high-value accounts
condition: "annual_revenue > 1000000"
// Only run for specific statuses
condition: "status IN ('pending', 'in_review')"
// Complex conditions
condition: "type = 'enterprise' AND region = 'APAC' AND is_active = true"onError — Error Handling
// Abort operation on error (default for before* hooks)
onError: 'abort'
// Log error and continue (default for after* hooks)
onError: 'log'---
Hook Context API
The HookContext passed to your handler provides:
Context Properties
interface HookContext {
// Immutable identifiers
id?: string; // Unique execution ID for tracing
object: string; // Target object name (e.g., 'account')
event: HookEventType; // Current event (e.g., 'beforeInsert')
// Mutable data
input: Record<string, unknown>; // Operation parameters (MUTABLE)
result?: unknown; // Operation result (MUTABLE, after* only)
previous?: Record<string, unknown>; // Previous state (update/delete)
// Execution context
session?: {
userId?: string;
tenantId?: string;
roles?: string[];
accessToken?: string;
};
transaction?: unknown; // Database transaction handle
// Engine access
ql: IDataEngine; // ObjectQL engine instance
api?: ScopedContext; // Cross-object CRUD API
// User info shortcut
user?: {
id?: string;
name?: string;
email?: string;
};
}input — Operation Parameters
The structure of ctx.input varies by event:
Insert operations:
// beforeInsert, afterInsert
{
// All field values being inserted
name: 'Acme Corp',
industry: 'Technology',
annual_revenue: 5000000,
...
}Update operations:
// beforeUpdate, afterUpdate
{
id: '123', // Record ID being updated
// Only fields being changed
status: 'active',
updated_at: '2026-04-13T10:00:00Z',
}Delete operations:
// beforeDelete, afterDelete
{
id: '123', // Record ID being deleted
}Query operations:
// beforeFind, afterFind
{
query: {
filter: { status: 'active' },
sort: [{ field: 'created_at', order: 'desc' }],
limit: 50,
offset: 0,
},
options: { includeCount: true },
}result — Operation Result
Available in after* hooks:
// afterInsert
result: { id: '123', name: 'Acme Corp', ... }
// afterUpdate
result: { id: '123', status: 'active', ... }
// afterDelete
result: { success: true, id: '123' }
// afterFind
result: {
records: [{ id: '1', ... }, { id: '2', ... }],
total: 150,
}previous — Previous State
Available in update/delete hooks:
// beforeUpdate, afterUpdate
ctx.previous: {
id: '123',
status: 'pending', // Old value
updated_at: '2026-04-01T00:00:00Z',
}
// beforeDelete, afterDelete
ctx.previous: {
id: '123',
name: 'Old Account',
// ... full record state
}Cross-Object API
Access other objects within the same transaction:
handler: async (ctx: HookContext) => {
// Get API for another object
const users = ctx.api?.object('user');
// Query users
const admin = await users.findOne({
filter: { role: 'admin' }
});
// Create related record
await ctx.api?.object('audit_log').insert({
action: 'account_created',
user_id: ctx.session?.userId,
record_id: ctx.input.id,
});
}---
Common Patterns
1. Setting Default Values
const setAccountDefaults: Hook = {
name: 'account_defaults',
object: 'account',
events: ['beforeInsert'],
handler: async (ctx) => {
// Set default industry
if (!ctx.input.industry) {
ctx.input.industry = 'Other';
}
// Set created timestamp
ctx.input.created_at = new Date().toISOString();
// Set owner to current user
if (!ctx.input.owner_id && ctx.session?.userId) {
ctx.input.owner_id = ctx.session.userId;
}
},
};2. Data Validation
const validateAccount: Hook = {
name: 'account_validation',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx) => {
// Validate email format
if (ctx.input.email && !ctx.input.email.includes('@')) {
throw new Error('Invalid email format');
}
// Validate website URL
if (ctx.input.website && !ctx.input.website.startsWith('http')) {
throw new Error('Website must start with http or https');
}
// Check annual revenue
if (ctx.input.annual_revenue && ctx.input.annual_revenue < 0) {
throw new Error('Annual revenue cannot be negative');
}
},
};3. Preventing Deletion
const protectStrategicAccounts: Hook = {
name: 'protect_strategic_accounts',
object: 'account',
events: ['beforeDelete'],
handler: async (ctx) => {
// ctx.previous contains the record being deleted
if (ctx.previous?.type === 'Strategic') {
throw new Error('Cannot delete Strategic accounts');
}
// Check for active opportunities
const oppCount = await ctx.api?.object('opportunity').count({
filter: {
account_id: ctx.input.id,
stage: { $in: ['Prospecting', 'Negotiation'] }
}
});
if (oppCount && oppCount > 0) {
throw new Error(`Cannot delete account with ${oppCount} active opportunities`);
}
},
};4. Data Enrichment
const enrichLeadScore: Hook = {
name: 'lead_scoring',
object: 'lead',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx) => {
let score = 0;
// Email domain scoring
if (ctx.input.email?.endsWith('@enterprise.com')) {
score += 50;
}
// Phone number bonus
if (ctx.input.phone) {
score += 20;
}
// Company size scoring
if (ctx.input.company_size === 'Enterprise') {
score += 30;
}
// Industry scoring
if (ctx.input.industry === 'Technology') {
score += 25;
}
ctx.input.score = score;
},
};5. Triggering Workflows
const notifyOnStatusChange: Hook = {
name: 'notify_status_change',
object: 'opportunity',
events: ['afterUpdate'],
async: true, // Fire-and-forget
handler: async (ctx) => {
// Detect status change
const oldStatus = ctx.previous?.stage;
const newStatus = ctx.input.stage;
if (oldStatus !== newStatus) {
// Send notification (async, doesn't block transaction)
console.log(`Opportunity ${ctx.input.id} moved from ${oldStatus} to ${newStatus}`);
// Could trigger email, Slack notification, etc.
// await sendEmail({
// to: ctx.user?.email,
// subject: `Opportunity stage changed to ${newStatus}`,
// body: `...`
// });
}
},
};6. Creating Related Records
const createAuditTrail: Hook = {
name: 'audit_trail',
object: ['account', 'contact', 'opportunity'],
events: ['afterInsert', 'afterUpdate', 'afterDelete'],
async: false, // Must run in transaction
handler: async (ctx) => {
const action = ctx.event.replace('after', '').toLowerCase();
await ctx.api?.object('audit_log').insert({
object_type: ctx.object,
record_id: String(ctx.input.id || ''),
action,
user_id: ctx.session?.userId,
timestamp: new Date().toISOString(),
changes: ctx.event === 'afterUpdate' ? {
before: ctx.previous,
after: ctx.result,
} : undefined,
});
},
};7. External API Integration
const syncToExternalCRM: Hook = {
name: 'sync_external_crm',
object: 'account',
events: ['afterInsert', 'afterUpdate'],
async: true, // Don't block the main transaction
timeout: 10000, // 10 second timeout
retryPolicy: {
maxRetries: 3,
backoffMs: 2000,
},
handler: async (ctx) => {
try {
// Call external API
// await fetch('https://external-crm.com/api/accounts', {
// method: 'POST',
// headers: { 'Authorization': 'Bearer ...' },
// body: JSON.stringify(ctx.result),
// });
console.log(`Synced account ${ctx.input.id} to external CRM`);
} catch (error) {
// Error is logged but doesn't abort the operation
console.error('Failed to sync to external CRM', error);
}
},
};8. Multi-Object Logic
const cascadeAccountUpdate: Hook = {
name: 'cascade_account_updates',
object: 'account',
events: ['afterUpdate'],
handler: async (ctx) => {
// If account industry changed, update all contacts
if (ctx.input.industry && ctx.previous?.industry !== ctx.input.industry) {
await ctx.api?.object('contact').updateMany({
filter: { account_id: ctx.input.id },
data: { account_industry: ctx.input.industry },
});
}
},
};9. Conditional Execution
const highValueAccountAlert: Hook = {
name: 'high_value_alert',
object: 'account',
events: ['afterInsert'],
// Only run for high-value accounts
condition: "annual_revenue > 10000000",
async: true,
handler: async (ctx) => {
console.log(`🚨 High-value account created: ${ctx.result.name}`);
// Send alert to sales leadership
},
};10. Data Masking (Read Operations)
const maskSensitiveData: Hook = {
name: 'mask_pii',
object: ['contact', 'lead'],
events: ['afterFind', 'afterFindOne'],
handler: async (ctx) => {
// Check user role
const isAdmin = ctx.session?.roles?.includes('admin');
if (!isAdmin) {
// Mask sensitive fields
const maskField = (record: any) => {
if (record.ssn) {
record.ssn = '***-**-' + record.ssn.slice(-4);
}
if (record.credit_card) {
record.credit_card = '**** **** **** ' + record.credit_card.slice(-4);
}
};
if (Array.isArray(ctx.result?.records)) {
ctx.result.records.forEach(maskField);
} else if (ctx.result) {
maskField(ctx.result);
}
}
},
};---
Registration Methods
Method 1: Declarative (Stack Definition) — RECOMMENDED
Best for: Application-level hooks defined as metadata. The AppPlugin auto-binds these onto the ObjectQL engine at startup — *no `registerHook boilerplate is required**, and all declarative fields (condition, async, retryPolicy, timeout, onError, priority`) are honoured by the runtime.
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';
import taskHook from './objects/task.hook';
export default defineStack({
manifest: { /* ... */ },
objects: [/* ... */],
hooks: [taskHook], // ← AppPlugin auto-binds; no manual registration needed
});For string-named handlers, declare them under functions so the binder can resolve them:
export default defineStack({
hooks: [
{ name: 'h', object: 'account', events: ['beforeInsert'], handler: 'normalize' },
],
functions: {
normalize: async (ctx) => { /* ... */ },
},
});Method 2: Programmatic (Runtime) — escape hatch
Best for: Plugins that need to register hooks dynamically based on runtime state. Prefer Method 1 unless you actually need imperative control.
// In your plugin's onEnable()
export const onEnable = async (ctx: { ql: ObjectQL }) => {
ctx.ql.registerHook('beforeInsert', async (hookCtx) => {
// Handler logic
}, {
object: 'account',
priority: 100,
packageId: 'my-plugin', // enables clean unregister later
});
};Note: hooks registered this way do not get the declarative
condition/retry/timeout/onError/asyncsemantics —
those only apply when binding through defineStack({ hooks }) orcalling ql.bindHooks([...]) directly.Method 3: Hook Files (Convention)
Best for: Organized codebases, per-object hooks.
// src/objects/account.hook.ts
import { Hook, HookContext } from '@objectstack/spec/data';
const accountHook: Hook = {
name: 'account_logic',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx: HookContext) => {
// Validation logic
},
};
export default accountHook;
// Then import and register in objectstack.config.ts---
Best Practices
✅ DO
1. Use specific events — Don't subscribe to all events if you only need one. 2. Keep handlers focused — One hook = one responsibility. 3. Use `condition` for filtering — Avoid unnecessary handler execution. 4. Set appropriate priorities — Ensure correct execution order. 5. Use `async: true` for side effects — Don't block transactions for non-critical operations. 6. Validate early — Use before* hooks for validation. 7. Handle errors gracefully — Provide meaningful error messages. 8. Use `ctx.api` for cross-object operations — Maintains transaction consistency. 9. Document your hooks — Use description and comments. 10. Test thoroughly — Unit test hooks in isolation.
❌ DON'T
1. Don't mutate immutable properties — ctx.object, ctx.event, ctx.id are read-only. 2. *Don't perform expensive operations in `before hooks** — Use after` + `async: true` instead. 3. Don't create infinite loops — Be careful when hooks modify data that triggers other hooks. 4. Don't ignore `ctx.previous` — Essential for detecting changes. 5. Don't use `object: '' unless necessary** — Performance impact. 6. **Don't block on external APIs** — Use async: true and proper timeouts. 7. **Don't assume ctx.session exists** — System operations may have no user context. 8. **Don't throw in after` hooks unless critical — Use `onError: 'log'` for non-critical errors. 9. Don't duplicate validation — Use declarative validation rules when possible. 10. Don't forget transaction boundaries* — async: true runs outside transaction.
---
Error Handling
Throwing Errors (Abort Operation)
handler: async (ctx) => {
if (!ctx.input.email) {
// Aborts operation, rolls back transaction
throw new Error('Email is required');
}
}Logging Errors (Continue)
{
onError: 'log', // Log error, don't abort
handler: async (ctx) => {
try {
await sendEmail(ctx.input.email);
} catch (error) {
// Error is logged, operation continues
console.error('Failed to send email', error);
}
}
}Custom Error Messages
handler: async (ctx) => {
if (ctx.input.annual_revenue < 0) {
throw new Error('Annual revenue cannot be negative');
}
if (ctx.input.annual_revenue > 1000000000) {
throw new Error('Annual revenue exceeds maximum allowed value (1B)');
}
}---
Testing Hooks
Unit Testing
import { describe, it, expect } from 'vitest';
import { HookContext } from '@objectstack/spec/data';
import accountHook from './account.hook';
describe('accountHook', () => {
it('sets default industry', async () => {
const ctx: Partial<HookContext> = {
object: 'account',
event: 'beforeInsert',
input: { name: 'Acme Corp' },
};
await accountHook.handler(ctx as HookContext);
expect(ctx.input.industry).toBe('Other');
});
it('validates website URL', async () => {
const ctx: Partial<HookContext> = {
object: 'account',
event: 'beforeInsert',
input: { website: 'invalid-url' },
};
await expect(
accountHook.handler(ctx as HookContext)
).rejects.toThrow('Website must start with http');
});
});Integration Testing
import { LiteKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { DriverPlugin } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
describe('Hook Integration', () => {
it('executes hook on insert', async () => {
const kernel = new LiteKernel();
kernel.use(new ObjectQLPlugin());
kernel.use(new DriverPlugin(new InMemoryDriver()));
// Register hook
const ql = kernel.getService('objectql');
ql.registerHook('beforeInsert', async (ctx) => {
ctx.input.created_at = '2026-04-13T10:00:00Z';
}, { object: 'account' });
// Test insert
const result = await ql.object('account').insert({
name: 'Test Account',
});
expect(result.created_at).toBe('2026-04-13T10:00:00Z');
await kernel.shutdown();
});
});---
Performance Considerations
Hook Execution Overhead
Single Record Insert:
┌─────────────────┬──────────────┐
│ Hook Count │ Overhead │
├─────────────────┼──────────────┤
│ 0 hooks │ ~1ms │
│ 5 hooks │ ~5ms │
│ 20 hooks │ ~20ms │
└─────────────────┴──────────────┘Optimization Tips
1. Use `condition` to filter — Avoid executing handlers unnecessarily. 2. Use `async: true` for non-critical side effects — Don't block transactions. 3. *Batch operations in `after hooks** — Reduce database round-trips. 4. **Cache expensive lookups** — Use kernel cache service. 5. **Use specific object targets** — Avoid object: '*'`.
Anti-Patterns
// ❌ BAD: Expensive synchronous operation
{
events: ['beforeInsert'],
async: false,
handler: async (ctx) => {
await slowExternalAPI(ctx.input); // Blocks transaction
}
}
// ✅ GOOD: Async background operation
{
events: ['afterInsert'],
async: true, // Fire-and-forget
handler: async (ctx) => {
await slowExternalAPI(ctx.result);
}
}---
Advanced Topics
Dynamic Hook Registration
// Register hooks based on configuration
export const onEnable = async (ctx: { ql: ObjectQL }) => {
const config = await loadConfig();
config.objects.forEach(objectName => {
ctx.ql.registerHook('beforeInsert', async (hookCtx) => {
// Dynamic logic
}, { object: objectName });
});
};Hook Composition
// Compose multiple validators
const validators = [
validateEmail,
validatePhone,
validateWebsite,
];
const composedHook: Hook = {
name: 'validation_suite',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx) => {
for (const validator of validators) {
await validator(ctx);
}
},
};Conditional Hook Execution
const conditionalHook: Hook = {
name: 'enterprise_only',
object: 'account',
events: ['afterInsert'],
handler: async (ctx) => {
// Check runtime condition
if (process.env.FEATURE_FLAG_ENTERPRISE !== 'true') {
return; // Skip execution
}
// Enterprise-specific logic
},
};---
Troubleshooting
Common Issues
Issue: Hook not executing
Solutions: 1. Check object matches target object name 2. Verify events includes the expected event 3. Check condition doesn't filter out all records 4. Ensure hook is registered before operations
Issue: Transaction rollback on after* hook error
Solution: Set onError: 'log' or async: true
Issue: Infinite loop (hook triggers itself)
Solution: Use conditional checks, track execution state
Issue: ctx.api is undefined
Solution: Ensure ObjectQL engine is initialized with API support
Issue: Performance degradation
Solutions: 1. Use async: true for non-critical operations 2. Add condition to filter executions 3. Reduce number of global (object: '*') hooks
---
References
- `@objectstack/spec/src/data/hook.zod.ts` — Hook schema definition, HookContext interface
- Examples: app-todo — Simple task hook
- Project hooks pattern — Hook integration in the data skill
---
Summary
Hooks are the primary extension mechanism in ObjectStack. They enable you to:
- ✅ Add custom validation and business rules
- ✅ Enrich data with calculated fields
- ✅ Trigger side effects and integrations
- ✅ Enforce security and compliance
- ✅ Implement audit trails
- ✅ Transform data in/out
Golden Rules:
1. Use before* for validation, after* for side effects 2. Set async: true for non-critical background work 3. Use ctx.api for cross-object operations 4. Handle errors gracefully with meaningful messages 5. Test hooks in isolation and integration
For more advanced patterns, see the objectstack-automation skill for Flows and Workflows.
Datasources & Federation
A datasource (defineDatasource, a *.datasource.ts metadata file) is a connection to a data store. Objects route to one via their datasource field (default: 'default'). Most apps need only the default datasource; declare more to read/write a separate or external database.
Full field reference: node_modules/@objectstack/spec/src/data/datasource.zod.ts. Narrative guide: content/docs/guides/external-datasources.mdx.
schemaMode — who owns the schema
| Mode | Meaning |
|---|---|
managed (default) | ObjectStack owns the schema; DDL + migrations allowed. |
external | A mature external DB ObjectStack does not own; DDL forbidden; boot mismatch fails. |
validate-only | Like external, but a mismatch warns instead of failing boot. |
external settings are required iff schemaMode !== 'managed' (and forbidden otherwise).
Federated (external) objects
An object on an external datasource binds to its remote table via external:
ObjectSchema.create({
name: 'ext_customer',
datasource: 'warehouse',
external: {
remoteName: 'customers', // remote TABLE (object name may differ)
// remoteSchema: 'public', // optional schema/namespace (pg/mysql)
// columnMap: { cust_region: 'region' }, // remoteColumn → localField
// writable: true, // per-object write opt-in (see below)
},
fields: { id: { type: 'text' }, name: { type: 'text' }, region: { type: 'text' } },
});✅ / ❌ Column mapping (ADR-0062 D7)
- ✅ Map remote columns with `external.columnMap` (
remoteColumn → localField). - ❌ Never set `field.columnName` on an external object. The driver's query
pipeline ignores it for federated objects, so it is a silent dual-source trap. os build / os validate rejects it with a clear error. (field.columnName on managed objects is unaffected.)
Auto-connect (no onEnable)
A declared datasource is built into a live driver, connected, and its federated objects' read metadata registered automatically at boot — no onEnable / ctx.drivers.register. It auto-connects when meaningfully addressed:
1. it is external (schemaMode !== 'managed'), or 2. an object explicitly binds via object.datasource === <name>, or 3. it sets `autoConnect: true`.
A managed datasource that nothing explicitly binds (e.g. only referenced by a datasourceMapping rule) stays metadata-only — visible but not connected — so existing apps are unchanged. Set autoConnect: true to force a live connection.
onEnable+ctx.drivers.register(driver)remains supported only as an escape
hatch for drivers built dynamically at runtime; it is idempotent with auto-connect.
Credentials — fail-closed
Never inline a password. Use external.credentialsRef and store the secret in the secret store; it is resolved at connect, before the driver is built. A declared credentialsRef that cannot be resolved/decrypted (or no secret store configured) leaves the datasource unconnected with a clear error — never connected without the credential.
Writes — double opt-in
Federation is read-only by default. To write, both gates must be on: datasource.external.allowWrites: true and the object's external.writable: true. With either off, insert/update/delete on the federated object is rejected.
Field Types Reference
Quick reference for choosing the right field type from 48 available options.
Text & Content
| Type | When to Use | Config |
|---|---|---|
text | Single-line strings (names, codes, titles) | maxLength, minLength, defaultValue |
textarea | Multi-line plain text (notes, descriptions) | maxLength, rows |
email | Email addresses — built-in format validation | required, unique |
url | Web URLs — built-in format validation | required |
phone | Phone numbers | format (custom regex) |
password | Masked / hashed input | minLength, hashAlgorithm |
markdown | Markdown-formatted content | maxLength |
html | Raw HTML content | maxLength, sanitize |
richtext | WYSIWYG rich text editor | maxLength |
Numbers
| Type | When to Use | Config |
|---|---|---|
number | Generic numeric value | min, max, precision, step |
currency | Monetary amounts | currencyConfig (precision, currencyMode, defaultCurrency) |
percent | Percentage values (0-100) | min, max, precision |
Date & Time
| Type | When to Use | Config |
|---|---|---|
date | Date only (no time component) | defaultValue, min, max |
datetime | Full date + time | defaultValue, timezone |
time | Time only (no date component) | defaultValue, format |
Logic
| Type | When to Use | Config |
|---|---|---|
boolean | Standard checkbox | defaultValue |
toggle | Toggle switch (distinct UI from checkbox) | defaultValue |
Selection
| Type | When to Use | Config |
|---|---|---|
select | Single-choice dropdown | options (value, label, color, default) |
multiselect | Tag-style multi-choice | options, max |
radio | Radio button group (fewer choices, always visible) | options |
checkboxes | Checkbox group | options |
Critical: Every option must have lowercase value and human-readable label.
options: [
{ label: 'In Progress', value: 'in_progress', color: '#3498db' },
{ label: 'Done', value: 'done', default: true },
]Relational
| Type | When to Use | Key Config |
|---|---|---|
lookup | Reference another object (independent) | reference, referenceFilters, multiple |
master_detail | Parent–child with lifecycle control | reference, deleteBehavior (cascade/restrict/set_null) |
tree | Hierarchical self-reference | reference |
Set multiple: true on lookup for many-to-many via junction.
Media
| Type | When to Use | Config |
|---|---|---|
image | Image files (PNG, JPG, GIF, WebP) | fileAttachmentConfig (maxSize, allowedTypes, storage) |
file | Generic file attachments | fileAttachmentConfig, allowedExtensions |
avatar | User/profile picture | fileAttachmentConfig, cropAspectRatio |
video | Video files | fileAttachmentConfig, maxDuration |
audio | Audio files | fileAttachmentConfig, maxDuration |
All use fileAttachmentConfig for size limits, allowed types, virus scanning, and storage provider.
Calculated
| Type | When to Use | Config |
|---|---|---|
formula | Computed from an expression referencing other fields | expression, resultType |
summary | Roll-up aggregation from child records | summaryType (count/sum/min/max/avg), summaryField, reference |
autonumber | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | format (e.g., "CASE-{0000}", "AD{YYYYMMDD}{0000}") |
Enhanced Types
| Type | When to Use | Config |
|---|---|---|
location | Geographic coordinates (lat/lng) | defaultZoom, enableSearch |
address | Structured address (street, city, country) | countryFilter, autocomplete |
code | Syntax-highlighted code editor | language, theme |
json | JSON data | schema (JSON Schema for validation) |
color | Color picker | format (hex/rgb/hsl), alpha |
rating | Star/heart rating | max (default 5), icon |
slider | Numeric slider | min, max, step |
signature | Digital signature pad | signatureConfig |
qrcode | QR code generator | qrConfig |
progress | Progress bar | min, max, showPercentage |
tags | Free-form tag input | max, delimiter, caseSensitive |
vector | AI/ML embeddings (semantic search, RAG) | vectorConfig (dimensions, distanceMetric, indexType) |
Field Type Decision Tree
What kind of data?
│
├── Text?
│ ├── Single line → text
│ ├── Multiple lines → textarea
│ ├── Formatted → richtext / markdown / html
│ ├── Email → email
│ ├── URL → url
│ ├── Phone → phone
│ └── Code → code
│
├── Number?
│ ├── Money → currency
│ ├── Percentage → percent
│ └── Generic → number
│
├── Date/Time?
│ ├── Date only → date
│ ├── Time only → time
│ └── Date + Time → datetime
│
├── True/False?
│ ├── Checkbox → boolean
│ └── Switch → toggle
│
├── Choose from list?
│ ├── Single choice, dropdown → select
│ ├── Single choice, always visible → radio
│ ├── Multiple choice, tags → multiselect
│ └── Multiple choice, checkboxes → checkboxes
│
├── Reference another object?
│ ├── Independent → lookup
│ ├── Owned child → master_detail
│ └── Hierarchy → tree
│
├── File/Media?
│ ├── Image → image
│ ├── Video → video
│ ├── Audio → audio
│ ├── User photo → avatar
│ └── Generic file → file
│
├── Calculated?
│ ├── Formula → formula
│ ├── Roll-up → summary
│ └── Auto-number → autonumber
│
└── Special?
├── Location → location
├── Address → address
├── Color → color
├── Rating → rating
├── Signature → signature
├── QR code → qrcode
├── Progress → progress
├── Tags → tags
├── JSON data → json
└── AI embeddings → vectorCommon Field Configurations
Text with Max Length
{
type: 'text',
maxLength: 255,
required: true,
}Email with Uniqueness
{
type: 'email',
required: true,
unique: true,
}Currency with Precision
{
type: 'currency',
currencyConfig: {
precision: 2,
currencyMode: 'fixed', // 'fixed' = one currency for the column;
// 'dynamic' = per-record `{ value, currency }`
defaultCurrency: 'USD', // ISO 4217
},
}Currency resolution (ADR-0053). A displayed amount resolves its symbol through: the field's own currencyConfig.defaultCurrency → the tenant localization.currency default. With neither set, renderers show a plain grouped number (never a hardcoded $). The same chain backs analytics measures (a measure's explicit currency wins over the field/tenant default).
Select with Default
{
type: 'select',
required: true,
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium', default: true },
{ label: 'High', value: 'high', color: '#e74c3c' },
],
}Lookup (One-to-Many)
{
type: 'lookup',
reference: 'account',
required: true,
referenceFilters: {
status: 'active',
},
}Lookup (Many-to-Many)
{
type: 'lookup',
reference: 'tag',
multiple: true,
max: 10,
}Master-Detail with Cascade
{
type: 'master_detail',
reference: 'invoice',
deleteBehavior: 'cascade',
required: true,
}Formula
{
type: 'formula',
expression: 'amount * tax_rate',
resultType: 'currency',
}Summary (Roll-up)
{
type: 'summary',
reference: 'invoice_line_item',
summaryType: 'sum',
summaryField: 'amount',
}Autonumber
{
type: 'autonumber',
format: 'CASE-{0000}',
}The format is literal text interleaved with {...} tokens:
| Token | Renders | Example |
|---|---|---|
{0000} | The counter, zero-padded to that many digits (minimum width). At most ONE slot. | CASE-{0000} → CASE-0042 |
{YYYY} {YY} {MM} {DD} {YYYYMMDD} | Generation date in the request's business timezone | AD{YYYYMMDD}{0000} → AD202606170001 |
{field_name} | The value of another field on the same record | {plan_no}{000} → PLAN-001001 |
The counter resets per "scope" — everything rendered before the {0000} slot. So AD{YYYYMMDD}{0000} restarts each day, {section}{island_zone}{000} counts per group, {plan_no}{000} counts per parent — no separate reset config. A fixed-prefix format (CASE-{0000}) has an empty scope → one global counter.
Rules — get these wrong and records mis-number silently or fail to save:
1. Every `{field}` you interpolate must be `required: true` and set before the record is created. An empty interpolated field makes the record number generation throw (the compile lint flags a non-existent field as an error, an optional one as a warning). 2. Put a delimiter between adjacent variable tokens — {section}-{zone}{000}, not {section}{zone}{000}. Without one, ('AB','C') and ('A','BC') both render prefix ABC and share a counter (to keep numbers unique). The literal separator keeps distinct groups apart. 3. Pad width is a MINIMUM, not a cap. {000} → 001…999, then 1000 (it grows, never wraps). Size it for readability, not as a ceiling. 4. Only known tokens are interpolated. Date tokens are case-sensitive and exact ({YYYY}, not {yyyy} or {YYYY-MM}). An unrecognized {...} is emitted literally into the number — { YYYY } (spaces) renders the text { YYYY }.
Vector (AI Embeddings)
{
type: 'vector',
vectorConfig: {
dimensions: 1536, // OpenAI ada-002
distanceMetric: 'cosine',
indexType: 'hnsw',
},
}Incorrect vs Correct
❌ Incorrect — Wrong Type for Email
{
type: 'text', // ❌ No built-in email validation
maxLength: 255,
}✅ Correct — Use email Type
{
type: 'email', // ✅ Built-in validation + UI affordances
}❌ Incorrect — Uppercase Option Value
options: [
{ label: 'Done', value: 'Done' }, // ❌ Uppercase
]✅ Correct — Lowercase Option Value
options: [
{ label: 'Done', value: 'done' }, // ✅ Lowercase
]❌ Incorrect — Missing Reference
{
type: 'lookup', // ❌ No reference specified
}✅ Correct — Specify Reference
{
type: 'lookup',
reference: 'account', // ✅ Target object specified
}❌ Incorrect — Autonumber interpolating an optional / adjacent field
{
plan_no: { type: 'text' }, // ❌ not required — empty value throws at create
order_no: { type: 'autonumber', format: '{section}{plan_no}{000}' }, // ❌ no delimiter
}✅ Correct — Required field + delimiter between variable tokens
{
plan_no: { type: 'text', required: true }, // ✅ always set before generation
order_no: { type: 'autonumber', format: '{section}-{plan_no}-{000}' }, // ✅ delimited
}Data Lifecycle Hooks (Reference)
Note: This document is a reference pointer. Complete documentation has been moved to the canonical hooks skill.
---
Complete Documentation
For comprehensive data lifecycle hooks documentation, see:
→ [objectstack-data/references/data-hooks.md](../../objectstack-data/references/data-hooks.md)
The canonical reference includes:
- All 14 lifecycle events (beforeFind, afterFind, beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeCount, afterCount, beforeAggregate, afterAggregate, beforeFindOne, afterFindOne)
- Complete Hook definition schema
- HookContext API reference
- Registration methods (declarative, programmatic, file-based)
- 10+ common patterns with full examples
- Performance considerations and optimization tips
- Testing strategies (unit and integration)
- Best practices and anti-patterns
---
Quick Reference
Hook Definition
import { Hook, HookContext } from '@objectstack/spec/data';
const hook: Hook = {
name: 'my_hook', // Required: unique identifier
object: 'account', // Required: target object(s)
events: ['beforeInsert'], // Required: lifecycle events
handler: async (ctx: HookContext) => {
// Your logic here
},
priority: 100, // Optional: execution order
async: false, // Optional: background execution (after* only)
condition: "status = 'active'", // Optional: conditional execution
};14 Lifecycle Events
| Event | When Fires | Use Case |
|---|---|---|
beforeFind | Before querying multiple records | Filter queries, log access |
afterFind | After querying multiple records | Transform results, mask data |
beforeFindOne | Before fetching single record | Validate permissions |
afterFindOne | After fetching single record | Enrich data |
beforeCount | Before counting records | Filter by context |
afterCount | After counting records | Log metrics |
beforeAggregate | Before aggregate operations | Validate rules |
afterAggregate | After aggregate operations | Transform results |
beforeInsert | Before creating a record | Set defaults, validate |
afterInsert | After creating a record | Send notifications |
beforeUpdate | Before updating a record | Validate changes |
afterUpdate | After updating a record | Trigger workflows |
beforeDelete | Before deleting a record | Check dependencies |
afterDelete | After deleting a record | Clean up related data |
Common Patterns
See the full documentation for complete examples of:
1. Setting Default Values — Auto-populate fields on insert 2. Data Validation — Custom validation rules beyond declarative 3. Preventing Deletion — Block deletes based on conditions 4. Data Enrichment — Calculate and set derived fields 5. Triggering Workflows — Fire notifications and integrations 6. Creating Related Records — Maintain referential integrity 7. External API Integration — Sync with external systems 8. Multi-Object Logic — Cascade updates across objects 9. Conditional Execution — Use condition property 10. Data Masking — PII protection in read operations
---
Registration
Three methods available:
1. Declarative (in Stack)
// objectstack.config.ts
export default defineStack({
hooks: [accountHook, contactHook],
});2. Programmatic (in Plugin)
ctx.ql.registerHook('beforeInsert', async (hookCtx) => {
// Handler logic
}, { object: 'account', priority: 100 });3. Hook Files (Convention)
// src/objects/account.hook.ts
export default {
name: 'account_logic',
object: 'account',
events: ['beforeInsert'],
handler: async (ctx) => { /* ... */ },
};---
Best Practices
✅ DO: 1. Use before* for validation, after* for side effects 2. Set async: true for non-critical background work 3. Use ctx.api for cross-object operations 4. Handle errors gracefully with meaningful messages 5. Test hooks in isolation and integration
❌ DON'T: 1. Don't perform expensive operations in before* hooks 2. Don't create infinite loops (hooks triggering themselves) 3. Don't use object: '*' unless absolutely necessary 4. Don't throw in after* hooks unless critical 5. Don't assume ctx.session exists
---
See Also
- [objectstack-data/SKILL.md#lifecycle-hooks](../../objectstack-data/SKILL.md#lifecycle-hooks) — Complete hooks system overview
- [objectstack-data/references/data-hooks.md](../../objectstack-data/references/data-hooks.md) — Full data hooks documentation
- [objectstack-platform/references/plugin-hooks.md](../../objectstack-platform/references/plugin-hooks.md) — Plugin hook system
- [objectstack-automation](../../objectstack-automation/SKILL.md) — Flows and Workflows for advanced automation
---
For complete documentation with detailed examples, context API reference, testing strategies, and performance optimization, see the canonical reference:
→ [objectstack-data/references/data-hooks.md](../../objectstack-data/references/data-hooks.md)
Index Strategy
Guide for creating efficient database indexes in ObjectStack.
Default Behavior
ObjectStack automatically creates indexes for:
- Primary keys (
id) - Foreign keys (lookup/master_detail fields)
- Unique constraints
Only declare non-default values. type defaults to 'btree' and unique defaults to false — omit them when using defaults.
Index Types
| Type | Default? | When to Use | Performance |
|---|---|---|---|
btree | ✅ Yes | Equality and range queries (=, <, >, BETWEEN) | Excellent |
hash | No | Exact equality only (=) — rare use case | Fast for =, poor for ranges |
fulltext | No | Text search columns (descriptions, notes) | Text search only |
gin | No | Array / JSONB containment, full-text search | JSONB, arrays, tags |
gist | No | Geospatial / range types | Location, geometry |
Syntax
indexes: [
{ fields: ['status', 'created_at'] }, // btree (default)
{ fields: ['email'], unique: true }, // btree + unique
{ fields: ['description'], type: 'fulltext' }, // non-default type
{ fields: ['tags'], type: 'gin' }, // non-default type
{ fields: ['location'], type: 'gist' }, // non-default type
]When to Add Indexes
✅ Always Index
1. Foreign keys — Automatic, but verify 2. Filter fields — Columns used in WHERE clauses 3. Sort fields — Columns used in ORDER BY 4. Unique constraints — Enforce uniqueness at DB level 5. Composite filters — Fields commonly filtered together
⚠️ Consider Indexing
1. Join columns — Non-foreign-key join fields 2. Frequent aggregations — GROUP BY columns 3. Range queries — Date ranges, numeric ranges 4. Partial data — Use partial indexes for subset queries
❌ Avoid Indexing
1. Low cardinality — Boolean fields (unless combined with others) 2. Rarely queried — Fields almost never filtered/sorted 3. High write volume — Every insert/update maintains indexes 4. Large text — Full-text index only when needed 5. Calculated fields — Index source fields instead
Examples
Composite Index (Multi-Column)
indexes: [
// Most specific first (status), then sort key
{ fields: ['status', 'created_at'] },
// Can satisfy queries like:
// - WHERE status = 'active'
// - WHERE status = 'active' ORDER BY created_at DESC
// - WHERE status = 'active' AND created_at > '2026-01-01'
]Unique Index
indexes: [
// Single column uniqueness
{ fields: ['email'], unique: true },
// Composite uniqueness
{ fields: ['tenant_id', 'username'], unique: true },
]Partial Index
indexes: [
// Only index active records
{
fields: ['created_at'],
partial: "status = 'active'",
},
// Only index non-deleted records
{
fields: ['email'],
unique: true,
partial: "deleted_at IS NULL",
},
]Full-Text Index
indexes: [
{
fields: ['description', 'notes'],
type: 'fulltext',
},
]GIN Index (JSONB/Array)
indexes: [
// JSONB field
{
fields: ['metadata'],
type: 'gin',
},
// Array field
{
fields: ['tags'],
type: 'gin',
},
]Geospatial Index (GIST)
indexes: [
{
fields: ['location'],
type: 'gist',
},
]Incorrect vs Correct
❌ Incorrect — Redundant Default Values
indexes: [
{ fields: ['status'], type: 'btree', unique: false }, // ❌ Redundant defaults
{ fields: ['email'], type: 'btree', unique: true }, // ❌ Redundant type
]✅ Correct — Omit Defaults
indexes: [
{ fields: ['status'] }, // ✅ btree and unique: false are defaults
{ fields: ['email'], unique: true }, // ✅ btree is default, only specify unique
]❌ Incorrect — Over-Indexing
indexes: [
{ fields: ['is_active'] }, // ❌ Boolean, low cardinality
{ fields: ['is_deleted'] }, // ❌ Boolean, low cardinality
{ fields: ['is_verified'] }, // ❌ Boolean, low cardinality
{ fields: ['status'] }, // ❌ Already indexed elsewhere
{ fields: ['created_at'] }, // ❌ Already indexed elsewhere
]✅ Correct — Strategic Indexing
indexes: [
// Composite for common query pattern
{ fields: ['is_active', 'created_at'] },
// Single index covers multiple queries
{ fields: ['status', 'priority'] },
]❌ Incorrect — Wrong Order in Composite
indexes: [
// Querying by created_at with status filter
{ fields: ['created_at', 'status'] }, // ❌ Wrong order
]✅ Correct — Most Selective First
indexes: [
// Status is more selective (filters more), goes first
{ fields: ['status', 'created_at'] }, // ✅ Correct order
]Composite Index Strategy
Order Matters
// Index: ['status', 'priority', 'created_at']
// ✅ Can use index
WHERE status = 'active'
WHERE status = 'active' AND priority = 'high'
WHERE status = 'active' AND priority = 'high' ORDER BY created_at
// ❌ Cannot use index efficiently
WHERE priority = 'high' // Skips first column
WHERE created_at > '2026-01-01' // Skips first two columnsLeft-to-Right Rule
Composite indexes are used left-to-right. Querying only the second or third column doesn't use the index.
Selectivity Rule
Place most selective (unique) fields first, then range/sort fields last.
// Good order: selective → range
{ fields: ['tenant_id', 'status', 'created_at'] }
// Bad order: range → selective
{ fields: ['created_at', 'status', 'tenant_id'] }Partial Indexes
Use partial indexes to index only a subset of rows:
// Only index active records (common query)
{
fields: ['created_at'],
partial: "status = 'active'",
}
// Only index high-value accounts
{
fields: ['annual_revenue'],
partial: "annual_revenue > 1000000",
}
// Only index non-deleted records
{
fields: ['email'],
unique: true,
partial: "deleted_at IS NULL",
}Benefits:
- Smaller index size
- Faster writes (fewer rows to maintain)
- Faster queries (focused data subset)
Performance Trade-offs
Index Benefits
- ✅ Faster SELECT queries
- ✅ Faster ORDER BY operations
- ✅ Faster JOIN operations
- ✅ Enforce uniqueness at DB level
Index Costs
- ❌ Slower INSERT/UPDATE/DELETE (index maintenance)
- ❌ Increased storage (each index duplicates data)
- ❌ Query planner overhead (more indexes = more choices)
General Guidelines
| Table Size | Max Indexes | Reasoning |
|---|---|---|
| < 1K rows | 2-3 | Low volume, indexes may not help |
| 1K - 100K rows | 3-5 | Balance read/write performance |
| 100K - 1M rows | 5-8 | Read optimization critical |
| > 1M rows | 8-12 | Consider partitioning + indexes |
Index Naming Convention
ObjectStack auto-generates index names. To specify custom names:
{
name: 'idx_account_status_created', // Custom name
fields: ['status', 'created_at'],
}Auto-generated pattern: idx_{object}_{field1}_{field2}_{...}
Monitoring Index Usage
Use database tools to monitor index usage:
-- PostgreSQL: Find unused indexes
SELECT
schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY schemaname, tablename;
-- MySQL: Check index cardinality
SHOW INDEX FROM your_table;Best Practices
1. Index foreign keys — Always (automatic in ObjectStack) 2. Composite for common queries — Combine frequently filtered columns 3. Order matters — Most selective field first 4. Partial for subsets — Index only relevant rows 5. Unique for constraints — Enforce at DB level 6. Monitor usage — Remove unused indexes 7. Limit total indexes — Balance read/write performance 8. Avoid over-indexing — More indexes ≠ better performance 9. Test with production data — Index effectiveness depends on data volume 10. Use EXPLAIN — Verify query plans before deploying indexes
Common Query Patterns
Filter by Status + Sort by Date
// Query: WHERE status = 'active' ORDER BY created_at DESC LIMIT 50
indexes: [
{ fields: ['status', 'created_at'] },
]Multi-Tenant Queries
// Query: WHERE tenant_id = X AND ...
indexes: [
{ fields: ['tenant_id', 'status', 'created_at'] },
]Text Search
// Query: WHERE description ILIKE '%keyword%'
indexes: [
{ fields: ['description'], type: 'fulltext' },
]Array/JSONB Containment
// Query: WHERE tags @> ['urgent']
indexes: [
{ fields: ['tags'], type: 'gin' },
]Location-Based Queries
// Query: WHERE ST_DWithin(location, point, distance)
indexes: [
{ fields: ['location'], type: 'gist' },
]Naming Conventions
ObjectStack enforces strict naming conventions to ensure consistency and machine readability.
Rules
| Context | Convention | Pattern | Example |
|---|---|---|---|
Object name | snake_case | /^[a-z_][a-z0-9_]*$/ | project_task |
| Field keys | snake_case | /^[a-z_][a-z0-9_]*$/ | first_name, due_date |
| Schema property keys (TS config) | camelCase | Standard JS | maxLength, referenceFilters |
Option value | lowercase machine ID | lowercase | in_progress |
Option label | Any case | — | "In Progress" |
Incorrect vs Correct
❌ Incorrect — Object Name
export default ObjectSchema.create({
name: 'ProjectTask', // ❌ PascalCase not allowed
fields: { /* ... */ }
});✅ Correct — Object Name
export default ObjectSchema.create({
name: 'project_task', // ✅ snake_case
fields: { /* ... */ }
});❌ Incorrect — Field Keys
fields: {
firstName: { type: 'text' }, // ❌ camelCase not allowed
'Due-Date': { type: 'datetime' }, // ❌ kebab-case not allowed
Status: { type: 'select' }, // ❌ PascalCase not allowed
}✅ Correct — Field Keys
fields: {
first_name: { type: 'text' }, // ✅ snake_case
due_date: { type: 'datetime' }, // ✅ snake_case
status: { type: 'select' }, // ✅ snake_case
}❌ Incorrect — Schema Properties
{
type: 'text',
max_length: 255, // ❌ snake_case not allowed for TS config
reference_filters: {}, // ❌ snake_case not allowed for TS config
}✅ Correct — Schema Properties
{
type: 'text',
maxLength: 255, // ✅ camelCase for TS config
referenceFilters: {}, // ✅ camelCase for TS config
}❌ Incorrect — Select Option Values
options: [
{ label: 'In Progress', value: 'In Progress' }, // ❌ space/caps in value
{ label: 'Done', value: 'Done' }, // ❌ uppercase in value
]✅ Correct — Select Option Values
options: [
{ label: 'In Progress', value: 'in_progress' }, // ✅ lowercase, snake_case
{ label: 'Done', value: 'done' }, // ✅ lowercase
]Critical Rules
1. Never use camelCase or PascalCase for object names or field keys 2. Always use camelCase for TypeScript configuration property keys 3. Option values must be lowercase machine identifiers (use snake_case for multi-word) 4. Option labels can use any case for display purposes 5. Machine names are immutable — changing them requires data migration
Rationale
- snake_case for data: Database-friendly, SQL-compatible, cross-platform consistency
- camelCase for config: TypeScript/JavaScript convention for object properties
- Lowercase option values: Case-sensitive database comparisons, URL-safe, API-friendly
Relationship Patterns
Guide for modeling relationships between objects using lookup, master_detail, and junction patterns.
Relationship Types
| Type | Lifecycle | Required | Sharing | Roll-ups | Use Case |
|---|---|---|---|---|---|
lookup | Independent | Optional by default | Independent | Not available | "Related to" |
master_detail | Coupled (cascade delete) | Always required | Inherits parent | Supported via summary | "Owned by" |
tree | Self-reference | Optional | N/A | Not available | Hierarchical |
When to Use lookup vs master_detail
Use lookup When:
- Child record can exist independently
- Parent deletion should not affect child
- No roll-up aggregations needed
- Relationship is optional
- Example:
task.assigned_to → user(task can exist without assignment)
Use master_detail When:
- Child record is meaningless without parent
- Parent deletion should cascade to children
- Need roll-up summaries (count, sum, min, max, avg)
- Relationship is mandatory
- Example:
invoice_line_item.invoice_id → invoice(line items belong to invoice)
Patterns
One-to-Many: lookup
// Parent: Account
export default ObjectSchema.create({
name: 'account',
fields: {
name: { type: 'text', required: true },
}
});
// Child: Contact (independent lifecycle)
export default ObjectSchema.create({
name: 'contact',
fields: {
first_name: { type: 'text', required: true },
account_id: {
type: 'lookup',
reference: 'account',
required: false, // Contact can exist without account
},
}
});One-to-Many: master_detail
// Parent: Invoice
export default ObjectSchema.create({
name: 'invoice',
fields: {
invoice_number: { type: 'text', required: true },
total: {
type: 'summary',
reference: 'invoice_line_item',
summaryType: 'sum',
summaryField: 'amount',
},
}
});
// Child: Line Item (owned by parent)
export default ObjectSchema.create({
name: 'invoice_line_item',
fields: {
invoice_id: {
type: 'master_detail',
reference: 'invoice',
deleteBehavior: 'cascade', // Delete line items when invoice deleted
required: true,
},
product: { type: 'text', required: true },
amount: { type: 'currency', required: true },
}
});Many-to-Many: Junction Object
// Side A: Project
export default ObjectSchema.create({
name: 'project',
fields: {
name: { type: 'text', required: true },
}
});
// Side B: Employee
export default ObjectSchema.create({
name: 'employee',
fields: {
name: { type: 'text', required: true },
}
});
// Junction: Project Assignment
export default ObjectSchema.create({
name: 'project_assignment',
fields: {
project_id: {
type: 'lookup',
reference: 'project',
required: true,
},
employee_id: {
type: 'lookup',
reference: 'employee',
required: true,
},
role: { type: 'text' },
hours_allocated: { type: 'number' },
},
validations: [
{
name: 'unique_assignment',
type: 'unique',
fields: ['project_id', 'employee_id'],
message: 'Employee already assigned to this project',
},
],
});Hierarchical: tree (Self-Reference)
export default ObjectSchema.create({
name: 'category',
fields: {
name: { type: 'text', required: true },
parent_category: {
type: 'tree',
reference: 'category', // Self-reference
required: false,
},
}
});Delete Behaviors
Configure deleteBehavior on master_detail relationships:
| Behavior | Effect | Use Case |
|---|---|---|
cascade | Delete all child records | Invoice → Line Items |
restrict | Prevent parent deletion if children exist | Department → Employees |
set_null | Set child reference to null | Manager → Employees (manager leaves) |
{
type: 'master_detail',
reference: 'parent_object',
deleteBehavior: 'cascade', // or 'restrict' or 'set_null'
}Roll-up Summaries
A parent summary field aggregates a child collection. The engine recomputes it server-side whenever a child is inserted/updated/deleted (inside the same transaction as the write, so it's consistent and never summed on the client).
// On the PARENT object:
{
type: 'summary',
summaryOperations: {
object: 'invoice_line', // child object to aggregate
field: 'amount', // child field to aggregate (ignored for count)
function: 'sum', // 'count' | 'sum' | 'min' | 'max' | 'avg'
// relationshipField: 'invoice' // optional; auto-detected from the child's
// master_detail/lookup field referencing
// this parent when omitted
},
}Empty collections roll up to 0 for count/sum, null for min/max/avg. Pairs naturally with inline editing (below): the parent total updates atomically as line items are saved.
Inline Editing (Master-Detail Entry)
Declare inline editing on the relationship, in the data model — not in a form view. Set inlineEdit: true on the child's master_detail (or lookup) field that points back to the parent. The parent's standard create/edit form then renders an editable grid for these children and saves parent + children in one atomic transaction — with no form-view config and no bespoke page. The UI is derived from metadata (relationship FK + child fields → grid columns).
// On the CHILD object's FK field:
export default ObjectSchema.create({
name: 'invoice_line',
fields: {
invoice: {
type: 'master_detail',
reference: 'invoice',
inlineEdit: true, // ← edited inline within the Invoice form
inlineTitle: 'Line Items', // optional grid title
// inlineColumns / inlineAmountField — optional overrides; columns are
// otherwise derived from this object's fields.
},
quantity: { type: 'number' },
amount: { type: 'currency' },
},
});Set `inlineEdit` only for true line-item / composition children (invoice lines, order items, expense lines) — the things a user enters together with the parent. Leave it off for associations (comments, attachments, activity, audit): those are also master_detail (cascade delete) but should NOT clutter the parent's entry form — surface them as related lists on the detail page.
A form view may still set subforms to override the derived columns/order, but the relationship inlineEdit is the primary, zero-config path. See the objectstack-ui skill (Master-Detail Forms) for the rendering side.
Inline-edit form factor (grid vs form)
inlineEdit also picks how the children are entered:
inlineEdit: true // auto — pick grid/form from the child's shape (default)
inlineEdit: 'grid' // editable line-item grid (fast bulk entry; thin children)
inlineEdit: 'form' // read-only list; "Add" / per-row edit opens the FULL form- `'grid'` — spreadsheet-like editable grid. Best for thin line items
(invoice/order lines): few columns, high volume, keyboard-fast.
- `'form'` — compact read-only list; Add / per-row edit opens the child's
complete form. Best for fat children (long text, attachments, many fields) that don't fit a narrow grid cell.
- `true` / omitted — smart default: picks
formwhen the child has
rich/form-only fields (textarea, file, image, json, location…) or more than ~8 editable fields, else grid. Set the string to override.
Modeling a `'grid'` line item for the full editor. The grid lights up extra behaviors purely from how you model the child + parent (no UI config — details in the objectstack-ui skill → Master-Detail Forms):
// Parent
Invoice = {
fields: {
tax_rate: Field.number({ label: 'Tax Rate (%)' }), // → live Subtotal/Tax/Total stack
total: Field.summary({ summaryOperations: { // server roll-up of the line subtotal
object: 'invoice_line', field: 'amount', function: 'sum' } }),
},
}
// Child line
InvoiceLine = {
fields: {
invoice: Field.masterDetail('invoice', { inlineEdit: 'grid' }),
position: Field.number({ defaultValue: 0 }), // → drag-reorder, persisted (auto-hidden col)
product: Field.lookup('product', { required: true }), // → catalog typeahead
description: Field.text(), // ← auto-filled from product.description
quantity: Field.number({ required: true, defaultValue: 1 }),
unit_price: Field.currency(), // ← auto-filled from product.unit_price
amount: Field.currency({ expression: 'record.quantity * record.unit_price' }), // computed, read-only, live
},
}- Computed column — a stored
currency/numberfield with anexpression
renders read-only and recomputes live in the grid, then persists. Keep it stored (NOT a formula field) so the parent summary can roll it up; the server only treats type: 'formula' as computed, so on a stored field the expression is a client compute hint and the sent value is stored verbatim.
- Catalog auto-fill — a
lookupline field + sibling columns whose names
match fields on the referenced record (e.g. unit_price, description) → picking a record fills those cells.
- Sort field — a numeric
position/sort_order/sequencefield is
auto-detected, hidden from the grid, and stamped on drag-reorder.
Detail-page related lists (the read-side mirror)
Where inlineEdit is the write side (child pulled into the parent's entry form), the related list on the parent's record detail page is the read side. You usually don't declare it: every child relationship (master_detail and lookup) is shown as a related list on the parent's detail page by default — owned (master_detail) children first. The relationship flags exist to refine that:
// On the CHILD object's FK field:
project: {
type: 'master_detail',
reference: 'project',
inlineEdit: true, // write side: edit inline in the Project form
relatedListTitle: 'Tasks', // read side: title of the detail-page list
relatedListColumns: ['title', 'status', 'priority', 'due_date'],
},
// Suppress a noisy association from the detail page entirely:
audit_ref: { type: 'master_detail', reference: 'invoice', relatedList: false },relatedList: false— suppress this child from the parent's detail page
(use for chatty association/log children you don't want surfaced).
relatedListTitle/relatedListColumns— override the derived title /
columns (columns are otherwise auto-derived from the child object's fields).
Audit FKs (created_by/updated_by/owner_id) never become related lists, and each child object yields at most one related list. See the objectstack-ui skill for the rendering side.
Incorrect vs Correct
❌ Incorrect — Using lookup When master_detail is Needed
// Invoice line items should NOT be independent
export default ObjectSchema.create({
name: 'invoice_line_item',
fields: {
invoice_id: {
type: 'lookup', // ❌ Child can exist without parent — wrong!
reference: 'invoice',
},
}
});✅ Correct — Using master_detail for Owned Children
export default ObjectSchema.create({
name: 'invoice_line_item',
fields: {
invoice_id: {
type: 'master_detail', // ✅ Child owned by parent
reference: 'invoice',
deleteBehavior: 'cascade',
required: true,
},
}
});❌ Incorrect — Native Many-to-Many
// ObjectStack does not have native many-to-many type
{
type: 'many_to_many', // ❌ Not a valid field type
reference: 'tag',
}✅ Correct — Junction Object Pattern
// Create explicit junction object with two lookup fields
export default ObjectSchema.create({
name: 'post_tag',
fields: {
post_id: { type: 'lookup', reference: 'post', required: true },
tag_id: { type: 'lookup', reference: 'tag', required: true },
},
});Best Practices
1. Use lookup by default — Only use master_detail when lifecycle coupling is required 2. Unique constraints on junctions — Prevent duplicate many-to-many entries 3. Meaningful junction names — Use descriptive names like project_assignment not project_employee 4. deleteBehavior on master_detail — Always specify cascade/restrict/set_null 5. Required on master_detail — Child should always require parent 6. Roll-ups for aggregation — Use summary fields on parent for counts/sums 7. referenceFilters for scoping — Limit lookup options to relevant records
Performance Considerations
- Index foreign keys — Always create indexes on lookup/master_detail fields
- Avoid deep hierarchies — tree relationships > 5 levels can impact query performance
- Junction table indexes — Composite index on both foreign keys in junction tables
- Summary field caching — Roll-up summaries are cached and updated on child changes
Validation Rules
Comprehensive guide for implementing validation rules in ObjectStack.
Available Rule Types
| Type | Purpose | When Validation Fails |
|---|---|---|
script | Formula expression | When expression evaluates to true |
unique | Composite uniqueness | When duplicate found |
state_machine | Legal state transitions | When transition not allowed |
format | Regex or built-in format | When format doesn't match |
cross_field | Compare values across fields | When comparison fails |
json_schema | Validate JSON field | When JSON doesn't match schema |
async | External API validation | When API returns error |
custom | Registered validator function | When function returns false |
conditional | Apply rule conditionally | When nested rule fails |
Script Validation
⚠️ CRITICAL: Script condition is inverted — validation fails when expression is true.
validations: [
{
name: 'prevent_past_dates',
type: 'script',
condition: 'due_date < TODAY()', // ❌ Fails when this is TRUE
message: 'Due date cannot be in the past',
severity: 'error',
events: ['insert', 'update'],
},
]Common Script Patterns
// Prevent negative values
condition: 'amount < 0'
// Require field when another field has value
condition: 'status = "approved" AND approver_id IS NULL'
// Date range validation
condition: 'end_date < start_date'
// Conditional required field
condition: 'type = "enterprise" AND account_manager IS NULL'Unique Validation
validations: [
{
name: 'unique_email',
type: 'unique',
fields: ['email'],
caseSensitive: false,
message: 'Email address already exists',
},
{
name: 'unique_tenant_email',
type: 'unique',
fields: ['tenant_id', 'email'], // Composite uniqueness
caseSensitive: false,
message: 'Email already exists in this tenant',
},
]State Machine Validation
validations: [
{
name: 'status_flow',
type: 'state_machine',
field: 'status',
transitions: {
draft: ['submitted', 'cancelled'],
submitted: ['in_review', 'cancelled'],
in_review: ['approved', 'rejected'],
approved: ['published'],
rejected: ['draft'],
published: [], // Terminal state
cancelled: [], // Terminal state
},
message: 'Invalid status transition',
severity: 'error',
},
]Format Validation
validations: [
// Built-in formats
{
name: 'email_format',
type: 'format',
field: 'email',
format: 'email', // Built-in: email, url, phone, json, uuid
message: 'Invalid email format',
},
// Custom regex
{
name: 'sku_format',
type: 'format',
field: 'sku',
pattern: '^[A-Z]{3}-\\d{4}$', // e.g., ABC-1234
message: 'SKU must be format: XXX-0000',
},
]Cross-Field Validation
validations: [
{
name: 'date_range',
type: 'cross_field',
condition: 'end_date > start_date',
message: 'End date must be after start date',
fields: ['start_date', 'end_date'],
},
{
name: 'discount_limit',
type: 'cross_field',
condition: 'discount_amount <= subtotal * 0.5',
message: 'Discount cannot exceed 50% of subtotal',
fields: ['discount_amount', 'subtotal'],
},
]JSON Schema Validation
validations: [
{
name: 'config_schema',
type: 'json_schema',
field: 'config',
schema: {
type: 'object',
properties: {
timeout: { type: 'number', minimum: 0 },
retries: { type: 'integer', minimum: 1, maximum: 5 },
enabled: { type: 'boolean' },
},
required: ['timeout', 'enabled'],
additionalProperties: false,
},
message: 'Invalid configuration format',
},
]Async Validation
validations: [
{
name: 'external_api_check',
type: 'async',
field: 'tax_id',
endpoint: 'https://api.example.com/validate/tax-id',
method: 'POST',
timeout: 5000,
debounce: 500, // Delay validation by 500ms
message: 'Invalid tax ID',
},
]Conditional Validation
validations: [
{
name: 'enterprise_requires_manager',
type: 'conditional',
condition: "type = 'enterprise'",
validations: [
{
name: 'manager_required',
type: 'script',
condition: 'account_manager IS NULL',
message: 'Enterprise accounts must have an account manager',
},
],
},
]Validation Properties
Severity Levels
severity: 'error' // Blocks save (default)
severity: 'warning' // Allows save, shows warning
severity: 'info' // Informational onlyEvents
events: ['insert'] // Only on create
events: ['update'] // Only on update
events: ['insert', 'update'] // On create and update (default)
events: ['delete'] // Only on deletePriority
priority: 0 // System validations (run first)
priority: 100 // Application validations (default)
priority: 1000 // User validations (run last)Lower numbers execute first.
Incorrect vs Correct
❌ Incorrect — Script Logic Inverted
{
type: 'script',
condition: 'amount > 0', // ❌ Fails when amount > 0 (inverted!)
message: 'Amount must be positive',
}✅ Correct — Script Logic
{
type: 'script',
condition: 'amount <= 0', // ✅ Fails when amount <= 0
message: 'Amount must be positive',
}❌ Incorrect — Missing Severity
{
type: 'script',
condition: 'end_date < start_date',
message: 'End date must be after start date',
// ❌ No severity — defaults to 'error' which may be too strict
}✅ Correct — Explicit Severity
{
type: 'script',
condition: 'end_date < start_date',
message: 'End date must be after start date',
severity: 'warning', // ✅ Allow save but warn user
}❌ Incorrect — Validation Fires Too Often
{
type: 'script',
condition: 'status = "draft"',
message: 'Record is still in draft',
// ❌ No events — runs on all operations
}✅ Correct — Validation Scoped to Events
{
type: 'script',
condition: 'status = "draft"',
message: 'Cannot publish draft records',
events: ['update'], // ✅ Only validate on update
}Common Patterns
Prevent Backdating
{
name: 'no_backdate',
type: 'script',
condition: 'created_at < TODAY()',
message: 'Cannot create records with past dates',
events: ['insert'],
}Require Approval for High Values
{
name: 'high_value_approval',
type: 'conditional',
condition: 'amount > 10000',
validations: [
{
type: 'script',
condition: 'approved_by IS NULL',
message: 'High-value transactions require approval',
},
],
}Email Domain Whitelist
{
name: 'email_domain',
type: 'format',
field: 'email',
pattern: '^[a-zA-Z0-9._%+-]+@(company\\.com|partner\\.com)$',
message: 'Email must be from company.com or partner.com',
}Phone Number Format
{
name: 'phone_format',
type: 'format',
field: 'phone',
pattern: '^\\+?[1-9]\\d{1,14}$', // E.164 format
message: 'Phone must be in international format (+1234567890)',
}Composite Unique (Tenant + Email)
{
name: 'tenant_email_unique',
type: 'unique',
fields: ['tenant_id', 'email'],
caseSensitive: false,
message: 'Email already exists in this tenant',
}Best Practices
1. Use declarative validation first — Only use script validation when declarative rules don't fit 2. Severity matters — Use warning for soft rules, error for hard rules 3. Events scope — Only validate on relevant operations to avoid overhead 4. Priority order — System validations first (0-99), app validations second (100-999), user validations last (1000+) 5. Clear error messages — Tell users exactly what's wrong and how to fix it 6. Async validation debounce — Use debounce to reduce API calls on fast typing 7. State machine for workflows — Use state_machine instead of complex script logic 8. Unique constraints — Always use unique validation, not script-based checks 9. Cross-field for comparisons — More efficient than script validation 10. Test thoroughly — Validate edge cases, nulls, empty strings
Performance Considerations
- Script validations are expensive — Use sparingly, prefer declarative rules
- Async validations add latency — Use debounce and appropriate timeouts
- Priority affects order — Lower priority = runs first
- Unique checks hit database — Index the unique fields for performance
- State machine is optimized — Better than complex conditional logic