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).
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.
- 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.
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)
ObjectStack provides 8 lifecycle events organized by operation type:
| Event | When It Fires | Use Cases |
|---|---|---|
| Read Operations | ||
beforeFind |
Before any read — find and findOne |
Filter queries by user context, log access |
afterFind |
After any read — find and findOne |
Transform results, enrich data |
| 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 (single or bulk multi:true) |
Validate changes, check permissions |
afterUpdate |
After updating a record (single or bulk) | Trigger workflows, sync external systems |
beforeDelete |
Before deleting a record (single or bulk multi:true) |
Check dependencies, prevent deletion |
afterDelete |
After deleting a record (single or bulk) | Clean up related data, notify users |
Why only 8? The read events fire for
findOneas well asfind(the event attaches to record materialization, not the engine method), so one subscription covers every read shape — there is nobeforeFindOne/afterFindOne. Likewise the write events fire on bulkmulti:trueoperations (the row-scoping predicate is inctx.input.ast), so there is no*Manyevent. And there is nobeforeCount/beforeAggregate: read authorization and row filtering belong to RLS / permission rules, and field masking to field-level metadata — declarative mechanisms that apply everywhere, rather than a hook every author must remember to re-attach.
| 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) |
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,
},
};// Single object
object: 'account'
// Multiple objects
object: ['account', 'contact', 'lead']
// All objects (use sparingly — performance impact)
object: '*'// Single event
events: ['beforeInsert']
// Multiple events (common pattern)
events: ['beforeInsert', 'beforeUpdate']
// After events for side effects
events: ['afterInsert', 'afterUpdate', 'afterDelete']Handlers can be:
-
Inline functions (recommended for simple hooks):
handler: async (ctx: HookContext) => { if (!ctx.input.email) { throw new Error('Email is required'); } }
-
String references (for registered handlers):
handler: 'my_plugin.validateAccount'
Lower numbers execute first:
// System hooks (framework internals)
priority: 50
// Application hooks (your app logic)
priority: 100 // default
// User customizations
priority: 1000Only 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
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"// Abort operation on error (default for before* hooks)
onError: 'abort'
// Log error and continue (default for after* hooks)
onError: 'log'The HookContext passed to your handler provides:
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;
organizationId?: string; // Active org — blessed name. Matches the
// `organization_id` column + `current_user.organizationId` (RLS)
tenantId?: string; // @deprecated alias of organizationId (identical value)
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 (undefined for system / unauthenticated writes)
user?: {
id?: string;
name?: string;
email?: string;
organizationId?: string; // Same value as session.organizationId
};
}The value a hook usually wants when it needs "the current org to filter/scope
by" is the caller's active organization — the same value that lives in the
organization_id column, in current_user.organizationId inside RLS/sharing
predicates, and in seed rows. Read it as organizationId:
// ✅ Blessed — matches columns, RLS `current_user`, and seed data
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
// ⚠️ Deprecated alias — still works, carries the identical value
const org = ctx.session?.tenantId;ctx.user is the ergonomic shortcut for an authenticated caller; it is
undefined for system / unauthenticated writes, so read ctx.session?.organizationId
when a hook must work regardless of whether a user resolved.
Two isolation axes — don't conflate them.
organization_idis org row-scoping: many organizations share one database and every row carries its owning org (current_user.organizationIdfilters reads/writes; multi-org needs cloud +@objectstack/organizations). That is different from environment / database-per-tenant isolation (service-tenant,driver-turso), where "tenant" means an entire environment/database and the generic driver-layertenantIdknob can carry that environment id. The object-metadatatenancy.*knob configures the mechanism (isolation on/off
- which column); the value you read and write is your
organization_idcolumn. Community edition never populates an org, soorganizationIdisundefinedthere.
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 },
}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,
}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
}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,
});
}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;
}
},
};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');
}
},
};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`);
}
},
};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;
},
};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: `...`
// });
}
},
};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,
});
},
};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);
}
},
};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 },
});
}
},
};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
},
};For static field masking (a field is always hidden/masked for a role), prefer declarative field-level metadata (secret/masked fields) — it applies on every read path automatically. Use an
afterFindhook only for masking that depends on runtime logic the field metadata can't express. A singleafterFindsubscription covers bothfindandfindOne.
const maskSensitiveData: Hook = {
name: 'mask_pii',
object: ['contact', 'lead'],
events: ['afterFind'], // fires for findOne too — no separate 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);
}
}
},
};Best for: Application-level hooks defined as metadata. The AppPlugin
auto-binds these onto the ObjectQL engine at startup — no register*Hook
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) => { /* ... */ },
},
});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 throughdefineStack({ hooks })or callingql.bindHooks([...])directly.
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- Use specific events — Don't subscribe to all events if you only need one.
- Keep handlers focused — One hook = one responsibility.
- Use
conditionfor filtering — Avoid unnecessary handler execution. - Set appropriate priorities — Ensure correct execution order.
- Use
async: truefor side effects — Don't block transactions for non-critical operations. - Validate early — Use
before*hooks for validation. - Handle errors gracefully — Provide meaningful error messages.
- Use
ctx.apifor cross-object operations — Maintains transaction consistency. - Document your hooks — Use
descriptionand comments. - Test thoroughly — Unit test hooks in isolation.
- Don't mutate immutable properties —
ctx.object,ctx.event,ctx.idare read-only. - Don't perform expensive operations in
before*hooks — Useafter*+async: trueinstead. - Don't create infinite loops — Be careful when hooks modify data that triggers other hooks.
- Don't ignore
ctx.previous— Essential for detecting changes. - Don't use
object: '*'unless necessary — Performance impact. - Don't block on external APIs — Use
async: trueand proper timeouts. - Don't assume
ctx.sessionexists — System operations may have no user context. - Don't throw in
after*hooks unless critical — UseonError: 'log'for non-critical errors. - Don't duplicate validation — Use declarative validation rules when possible.
- Don't forget transaction boundaries —
async: trueruns outside transaction.
handler: async (ctx) => {
if (!ctx.input.email) {
// Aborts operation, rolls back transaction
throw new Error('Email is required');
}
}{
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);
}
}
}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)');
}
}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');
});
});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();
});
});Single Record Insert:
┌─────────────────┬──────────────┐
│ Hook Count │ Overhead │
├─────────────────┼──────────────┤
│ 0 hooks │ ~1ms │
│ 5 hooks │ ~5ms │
│ 20 hooks │ ~20ms │
└─────────────────┴──────────────┘
- Use
conditionto filter — Avoid executing handlers unnecessarily. - Use
async: truefor non-critical side effects — Don't block transactions. - Batch operations in
after*hooks — Reduce database round-trips. - Cache expensive lookups — Use kernel cache service.
- Use specific
objecttargets — Avoidobject: '*'.
// ❌ 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);
}
}// 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 });
});
};// 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);
}
},
};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
},
};Issue: Hook not executing
Solutions:
- Check
objectmatches target object name - Verify
eventsincludes the expected event - Check
conditiondoesn't filter out all records - 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:
- Use
async: truefor non-critical operations - Add
conditionto filter executions - Reduce number of global (
object: '*') hooks
@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
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:
- Use
before*for validation,after*for side effects - Set
async: truefor non-critical background work - Use
ctx.apifor cross-object operations - Handle errors gracefully with meaningful messages
- Test hooks in isolation and integration
For more advanced patterns, see the objectstack-automation skill for Flows and Workflows.