Skip to content

Latest commit

 

History

History
1098 lines (864 loc) · 29.2 KB

File metadata and controls

1098 lines (864 loc) · 29.2 KB

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 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 findOne as well as find (the event attaches to record materialization, not the engine method), so one subscription covers every read shape — there is no beforeFindOne/afterFindOne. Likewise the write events fire on bulk multi:true operations (the row-scoping predicate is in ctx.input.ast), so there is no *Many event. And there is no beforeCount/ 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.

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: 1000

async — Background Execution

Only applicable for after* events:

// Blocking (default) — runs within transaction
async: false

// Fire-and-forget — runs in background
async: true

When 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;
    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
  };
}

Reading the current organization

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_id is org row-scoping: many organizations share one database and every row carries its owning org (current_user.organizationId filters 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-layer tenantId knob can carry that environment id. The object-metadata tenancy.* knob configures the mechanism (isolation on/off

  • which column); the value you read and write is your organization_id column. Community edition never populates an org, so organizationId is undefined there.

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)

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 afterFind hook only for masking that depends on runtime logic the field metadata can't express. A single afterFind subscription covers both find and findOne.

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);
      }
    }
  },
};

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 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) => { /* ... */ },
  },
});

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 / async semantics — those only apply when binding through defineStack({ hooks }) or calling 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 propertiesctx.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 boundariesasync: 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


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.