From 304924e185771d711bb1b7a6178778727f9e9715 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:31:23 +0000 Subject: [PATCH] refactor(skills): consolidate hooks documentation to eliminate duplication - Revived objectstack-hooks skill as canonical reference (removed deprecation) - Created objectstack-hooks/references/ with data-hooks.md and plugin-hooks.md - Replaced objectstack-schema/rules/hooks.md with reference pointer - Replaced objectstack-plugin/rules/hooks-events.md with reference pointer - Updated all cross-references in SKILL.md files - Established single source of truth for all hooks documentation Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/121cb9bd-5a46-4612-bc44-314fe4196da5 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- skills/objectstack-hooks/SKILL.md | 164 ++- .../references/data-hooks.md | 1038 ++++++++++++++++ .../references/plugin-hooks.md | 357 ++++++ skills/objectstack-plugin/SKILL.md | 4 +- .../objectstack-plugin/rules/hooks-events.md | 352 ++---- skills/objectstack-schema/SKILL.md | 6 +- skills/objectstack-schema/rules/hooks.md | 1059 ++--------------- 7 files changed, 1688 insertions(+), 1292 deletions(-) create mode 100644 skills/objectstack-hooks/references/data-hooks.md create mode 100644 skills/objectstack-hooks/references/plugin-hooks.md diff --git a/skills/objectstack-hooks/SKILL.md b/skills/objectstack-hooks/SKILL.md index 831429636..1308634a7 100644 --- a/skills/objectstack-hooks/SKILL.md +++ b/skills/objectstack-hooks/SKILL.md @@ -1,67 +1,155 @@ --- name: objectstack-hooks description: > - ⚠️ DEPRECATED: This skill has been integrated into objectstack-schema. - Please use the objectstack-schema skill and refer to rules/hooks.md for - data lifecycle hook documentation. + Canonical reference for all ObjectStack hooks and lifecycle events. + Use when implementing data lifecycle hooks, plugin hooks, kernel events, + or understanding the hook system architecture. This skill serves as the + single source of truth for hook documentation across the platform. license: Apache-2.0 compatibility: Requires @objectstack/spec v4+, @objectstack/objectql v4+ metadata: author: objectstack-ai - version: "1.0" + version: "2.0" domain: hooks - tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment - deprecated: true - replacement: objectstack-schema + tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment, events, plugin-hooks --- -# ⚠️ DEPRECATED: Hooks Skill Migrated +# Hooks System — ObjectStack Canonical Reference -This skill has been **deprecated** and integrated into the **objectstack-schema** skill. +This skill is the **single source of truth** for all hook and event documentation in ObjectStack. +It consolidates hook patterns across data lifecycle, plugin system, and kernel events to eliminate +duplication and ensure consistency. -## Migration +--- -For data lifecycle hook documentation, please use: +## When to Use This Skill -**New location:** [`objectstack-schema/rules/hooks.md`](../objectstack-schema/rules/hooks.md) +- You need to understand the **complete hooks architecture** across ObjectStack +- You are implementing **data lifecycle hooks** (beforeInsert, afterUpdate, etc.) +- You are working with **plugin hooks** (kernel:ready, data:*, custom events) +- You need to compare **data hooks vs plugin hooks** patterns +- You are designing hook-based integrations or extensions +- You want to reference hook best practices and common patterns -## Rationale +--- -Hooks are a core part of data operations and are best documented alongside object definitions, field types, and validations. The objectstack-schema skill now provides comprehensive coverage of: +## Skill Organization -- Object and field schema design -- Validation rules -- Index strategy -- **Data lifecycle hooks** (before/after patterns, all 14 events) +This skill is organized into two main reference documents: -This consolidation reduces skill overlap and makes it easier for AI assistants to understand the complete data layer in ObjectStack. +### 1. **Data Lifecycle Hooks** → [references/data-hooks.md](./references/data-hooks.md) -## What Was Moved +Comprehensive guide for data operation hooks: +- 14 lifecycle events (beforeFind, afterFind, beforeInsert, afterInsert, etc.) +- Hook definition schema and HookContext API +- Registration methods (declarative, programmatic, file-based) +- Common patterns (validation, defaults, audit logging, workflows) +- Performance considerations and best practices +- Complete code examples and testing strategies -All content from this skill is now available at: +**Use for:** Object-level business logic, validation, data enrichment, side effects during CRUD operations. -- **Full documentation:** [`../objectstack-schema/rules/hooks.md`](../objectstack-schema/rules/hooks.md) -- **Parent skill:** [`../objectstack-schema/SKILL.md`](../objectstack-schema/SKILL.md) +### 2. **Plugin & Kernel Hooks** → [references/plugin-hooks.md](./references/plugin-hooks.md) -The objectstack-schema skill now includes: -- Hook definition schema -- Hook context API -- 14 lifecycle events (before/after for find/insert/update/delete, etc.) -- Common patterns (validation, defaults, audit logging, workflows) -- Performance considerations -- Testing hooks -- Best practices +Guide for plugin-level hooks and event system: +- Kernel lifecycle hooks (kernel:ready, kernel:shutdown) +- Plugin event system (ctx.hook, ctx.trigger) +- Built-in data events (data:beforeInsert, data:afterInsert, etc.) +- Custom plugin events and namespacing +- Hook handler patterns and error handling +- Cross-plugin communication + +**Use for:** Plugin development, kernel events, cross-plugin communication, system-level hooks. + +--- + +## Quick Comparison: Data Hooks vs Plugin Hooks + +| Aspect | Data Hooks | Plugin Hooks | +|:-------|:-----------|:-------------| +| **Defined in** | Object metadata (Stack) | Plugin code (init/start) | +| **Registration** | `hooks: [...]` in stack config | `ctx.hook()` in plugin | +| **Context** | Rich HookContext with input/result/api | Flexible arguments per event | +| **Scope** | Object-specific or global (`object: '*'`) | Global, across all objects | +| **Priority** | Explicit `priority` field | Plugin dependency order | +| **Use case** | Business logic tied to objects | System integration, cross-cutting concerns | + +--- + +## References from Other Skills + +This skill is referenced by: + +- **[objectstack-schema](../objectstack-schema/SKILL.md)** — Uses data hooks for object lifecycle +- **[objectstack-plugin](../objectstack-plugin/SKILL.md)** — Uses plugin hooks for kernel integration +- **[objectstack-automation](../objectstack-automation/SKILL.md)** — Flows can trigger via hooks + +--- + +## Documentation Map + +``` +objectstack-hooks/ +├── SKILL.md (this file) +└── references/ + ├── data-hooks.md — Data lifecycle hooks (14 events) + └── plugin-hooks.md — Plugin & kernel hooks +``` --- -## Quick Reference (for backwards compatibility) +## Quick Start + +### For Data Lifecycle Hooks + +See [references/data-hooks.md](./references/data-hooks.md) for complete documentation. + +Quick example: +```typescript +import { Hook, HookContext } from '@objectstack/spec/data'; + +const hook: Hook = { + name: 'validate_account', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: HookContext) => { + if (!ctx.input.email?.includes('@')) { + throw new Error('Valid email required'); + } + }, +}; +``` + +### For Plugin Hooks -For hook lifecycle documentation, see: +See [references/plugin-hooks.md](./references/plugin-hooks.md) for complete documentation. -- [objectstack-schema/rules/hooks.md](../objectstack-schema/rules/hooks.md) — Complete hook documentation -- [objectstack-schema/SKILL.md](../objectstack-schema/SKILL.md) — Schema skill overview +Quick example: +```typescript +async init(ctx: PluginContext) { + ctx.hook('kernel:ready', async () => { + ctx.logger.info('System ready'); + }); + + ctx.hook('data:afterInsert', async (objectName, record, result) => { + console.log(`Created ${objectName}:`, result.id); + }); +} +``` + +--- + +## Design Philosophy + +1. **Single Source of Truth** — All hook documentation lives here, other skills reference it +2. **Clear Separation** — Data hooks vs plugin hooks serve different purposes +3. **Consistency** — Patterns and best practices apply across both systems +4. **Cross-References** — Easy navigation between related concepts + +--- -For kernel-level hooks (kernel:ready, kernel:shutdown, custom plugin events), see: +## See Also -- [objectstack-plugin/rules/hooks-events.md](../objectstack-plugin/rules/hooks-events.md) — Plugin hook system -- [objectstack-plugin/SKILL.md](../objectstack-plugin/SKILL.md) — Plugin skill overview +- [objectstack-schema/SKILL.md](../objectstack-schema/SKILL.md) — Object and field definitions +- [objectstack-plugin/SKILL.md](../objectstack-plugin/SKILL.md) — Plugin development guide +- [objectstack-automation/SKILL.md](../objectstack-automation/SKILL.md) — Flows and workflows diff --git a/skills/objectstack-hooks/references/data-hooks.md b/skills/objectstack-hooks/references/data-hooks.md new file mode 100644 index 000000000..bb503e1d6 --- /dev/null +++ b/skills/objectstack-hooks/references/data-hooks.md @@ -0,0 +1,1038 @@ +--- +name: objectstack-hooks +description: > + Write ObjectStack data lifecycle hooks for third-party plugins and applications. + Use when implementing business logic, validation, side effects, or data transformations + during CRUD operations. Covers hook registration, handler patterns, context API, + error handling, async execution, and integration with the ObjectQL engine. +license: Apache-2.0 +compatibility: Requires @objectstack/spec v4+, @objectstack/objectql v4+ +metadata: + author: objectstack-ai + version: "1.0" + domain: hooks + tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment +--- + +# 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`: + +```typescript +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 + +```typescript +// Single object +object: 'account' + +// Multiple objects +object: ['account', 'contact', 'lead'] + +// All objects (use sparingly — performance impact) +object: '*' +``` + +#### `events` — Lifecycle Events + +```typescript +// 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): + ```typescript + handler: async (ctx: HookContext) => { + if (!ctx.input.email) { + throw new Error('Email is required'); + } + } + ``` + +2. **String references** (for registered handlers): + ```typescript + handler: 'my_plugin.validateAccount' + ``` + +#### `priority` — Execution Order + +Lower numbers execute first: + +```typescript +// 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: + +```typescript +// 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: + +```typescript +// 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 + +```typescript +// 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 + +```typescript +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; // Operation parameters (MUTABLE) + result?: unknown; // Operation result (MUTABLE, after* only) + previous?: Record; // 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:** +```typescript +// beforeInsert, afterInsert +{ + // All field values being inserted + name: 'Acme Corp', + industry: 'Technology', + annual_revenue: 5000000, + ... +} +``` + +**Update operations:** +```typescript +// beforeUpdate, afterUpdate +{ + id: '123', // Record ID being updated + // Only fields being changed + status: 'active', + updated_at: '2026-04-13T10:00:00Z', +} +``` + +**Delete operations:** +```typescript +// beforeDelete, afterDelete +{ + id: '123', // Record ID being deleted +} +``` + +**Query operations:** +```typescript +// 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: + +```typescript +// 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: + +```typescript +// 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: + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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 + +```typescript +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) + +```typescript +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) + +**Best for:** Application-level hooks defined in metadata. + +```typescript +// objectstack.config.ts +import { defineStack } from '@objectstack/spec'; +import taskHook from './objects/task.hook'; + +export default defineStack({ + manifest: { /* ... */ }, + objects: [/* ... */], + hooks: [taskHook], // Register hooks here +}); +``` + +### Method 2: Programmatic (Runtime) + +**Best for:** Plugin-provided hooks, dynamic registration. + +```typescript +// In your plugin's onEnable() +export const onEnable = async (ctx: { ql: ObjectQL }) => { + ctx.ql.registerHook('beforeInsert', async (hookCtx) => { + // Handler logic + }, { + object: 'account', + priority: 100, + }); +}; +``` + +### Method 3: Hook Files (Convention) + +**Best for:** Organized codebases, per-object hooks. + +```typescript +// 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) + +```typescript +handler: async (ctx) => { + if (!ctx.input.email) { + // Aborts operation, rolls back transaction + throw new Error('Email is required'); + } +} +``` + +### Logging Errors (Continue) + +```typescript +{ + 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 + +```typescript +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 + +```typescript +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 = { + 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 = { + object: 'account', + event: 'beforeInsert', + input: { website: 'invalid-url' }, + }; + + await expect( + accountHook.handler(ctx as HookContext) + ).rejects.toThrow('Website must start with http'); + }); +}); +``` + +### Integration Testing + +```typescript +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 + +```typescript +// ❌ 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 + +```typescript +// 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 + +```typescript +// 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 + +```typescript +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 + +- [hook.zod.ts](./references/data/hook.zod.ts) — Hook schema definition, HookContext interface +- [Examples: app-todo](../../examples/app-todo/src/objects/task.hook.ts) — Simple task hook +- [Examples: app-crm](../../examples/app-crm/src/objects/) — Advanced CRM hooks + +--- + +## 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. diff --git a/skills/objectstack-hooks/references/plugin-hooks.md b/skills/objectstack-hooks/references/plugin-hooks.md new file mode 100644 index 000000000..c483c6266 --- /dev/null +++ b/skills/objectstack-hooks/references/plugin-hooks.md @@ -0,0 +1,357 @@ +# Hook & Event System + +Complete guide for using hooks and events in ObjectStack plugins. + +## Hook Registration + +Register hook handlers in `init()` or `start()`: + +```typescript +async init(ctx: PluginContext) { + // Register a hook handler + ctx.hook('kernel:ready', async () => { + ctx.logger.info('System is ready!'); + }); + + // Register data lifecycle hooks + ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'task') { + record.created_at = new Date().toISOString(); + } + }); +} +``` + +## Triggering Events + +Trigger custom hooks to notify other plugins: + +```typescript +async start(ctx: PluginContext) { + // Trigger a custom event + await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); +} +``` + +## Built-in Hooks + +### Kernel Lifecycle Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `kernel:ready` | All plugins started, system validated | (none) | +| `kernel:shutdown` | Shutdown begins | (none) | + +### Data Lifecycle Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `data:beforeInsert` | Before a record is created | `(objectName, record)` | +| `data:afterInsert` | After a record is created | `(objectName, record, result)` | +| `data:beforeUpdate` | Before a record is updated | `(objectName, id, record)` | +| `data:afterUpdate` | After a record is updated | `(objectName, id, record, result)` | +| `data:beforeDelete` | Before a record is deleted | `(objectName, id)` | +| `data:afterDelete` | After a record is deleted | `(objectName, id, result)` | +| `data:beforeFind` | Before querying records | `(objectName, query)` | +| `data:afterFind` | After querying records | `(objectName, query, result)` | + +### Metadata Hooks + +| Hook | Triggered When | Arguments | +|:-----|:---------------|:----------| +| `metadata:changed` | Metadata is registered or updated | `(type, name, metadata)` | + +## Custom Hooks + +Create your own hooks following the convention: `{plugin-namespace}:{event-name}`. + +```typescript +// In your plugin +async start(ctx: PluginContext) { + await ctx.trigger('analytics:pageview', { + path: '/dashboard', + userId: '123', + }); +} + +// In another plugin +async init(ctx: PluginContext) { + ctx.hook('analytics:pageview', async (data) => { + console.log('Page viewed:', data.path); + }); +} +``` + +## Hook Handler Patterns + +### Simple Handler + +```typescript +ctx.hook('kernel:ready', async () => { + console.log('System ready'); +}); +``` + +### Handler with Data + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + console.log(`Created ${objectName} record:`, result.id); +}); +``` + +### Handler with Context + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + // Access kernel context + const user = ctx.getService('auth').getCurrentUser(); + record.created_by = user.id; +}); +``` + +### Async Error Handling + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + try { + await sendNotification(record); + } catch (error) { + ctx.logger.error('Failed to send notification', error); + // Don't throw — let other hooks continue + } +}); +``` + +## Incorrect vs Correct + +### ❌ Incorrect — Blocking Hook with Slow Operation + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + // ❌ Blocks transaction + await sendEmail(record.email); + await callExternalAPI(record); +}); +``` + +### ✅ Correct — Use after* Hook for Side Effects + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + // ✅ Non-blocking, outside transaction + try { + await sendEmail(record.email); + await callExternalAPI(record); + } catch (error) { + ctx.logger.error('Side effect failed', error); + } +}); +``` + +### ❌ Incorrect — Throwing in after* Hook + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + throw new Error('Notification failed'); // ❌ Too late to abort +}); +``` + +### ✅ Correct — Logging Errors in after* Hook + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) { + try { + await sendNotification(result); + } catch (error) { + ctx.logger.error('Notification failed', error); // ✅ Log, don't throw + } +}); +``` + +### ❌ Incorrect — Modifying result in before* Hook + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + record.result = { id: '123' }; // ❌ result doesn't exist yet +}); +``` + +### ✅ Correct — Modifying input in before* Hook + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) { + record.created_at = new Date().toISOString(); // ✅ Modify input +}); +``` + +## Common Patterns + +### Setting Defaults + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'task') { + record.status = record.status || 'pending'; + record.priority = record.priority || 'medium'; + } +}); +``` + +### Audit Logging + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + const audit = ctx.getService('audit'); + await audit.log({ + action: 'create', + object: objectName, + recordId: result.id, + timestamp: new Date().toISOString(), + }); +}); +``` + +### Triggering Workflows + +```typescript +ctx.hook('data:afterUpdate', async (objectName, id, record, result) => { + if (objectName === 'opportunity' && record.stage === 'won') { + await ctx.trigger('sales:opportunity-won', { id, record: result }); + } +}); +``` + +### Cross-Object Updates + +```typescript +ctx.hook('data:afterInsert', async (objectName, record, result) => { + if (objectName === 'invoice_line_item') { + // Update invoice total + const engine = ctx.getService('objectql'); + await engine.object('invoice').update(record.invoice_id, { + updated_at: new Date().toISOString(), + }); + } +}); +``` + +### Validation + +```typescript +ctx.hook('data:beforeInsert', async (objectName, record) => { + if (objectName === 'account') { + if (!record.email || !record.email.includes('@')) { + throw new Error('Valid email is required'); + } + } +}); +``` + +## Hook Execution Order + +Hooks are executed in **registration order** within each plugin, then by **plugin initialization order**. + +```typescript +// Plugin A (depends on nothing) +ctx.hook('kernel:ready', () => console.log('A')); + +// Plugin B (depends on A) +ctx.hook('kernel:ready', () => console.log('B')); + +// Output: A, B +``` + +## Performance Considerations + +### before* Hooks +- ⚠️ Block the operation — keep fast +- ⚠️ Run inside transaction — don't call slow APIs +- ✅ Use for validation and data enrichment +- ✅ Throw errors to abort operation + +### after* Hooks +- ⚠️ Still block by default — use sparingly +- ✅ Use for notifications and logging +- ✅ Use try/catch to prevent cascading failures +- ✅ Consider async execution (if supported) + +## Hook Naming Conventions + +Follow the pattern: `{namespace}:{event-name}` + +**Good names:** +- `auth:user-login` +- `sales:opportunity-created` +- `billing:invoice-paid` +- `analytics:event-tracked` + +**Bad names:** +- `userLogin` (no namespace) +- `auth.user.login` (use colons, not dots) +- `auth:USER_LOGIN` (use lowercase) + +## Testing Hooks + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import MyPlugin from './plugin'; + +describe('Hook System', () => { + it('executes hook handler', async () => { + const kernel = new LiteKernel(); + let hookCalled = false; + + kernel.use({ + name: 'test-plugin', + async init(ctx) { + ctx.hook('test:event', async () => { + hookCalled = true; + }); + }, + }); + + await kernel.bootstrap(); + await kernel.context.trigger('test:event'); + + expect(hookCalled).toBe(true); + + await kernel.shutdown(); + }); + + it('passes arguments to hook handler', async () => { + const kernel = new LiteKernel(); + let receivedData: any; + + kernel.use({ + name: 'test-plugin', + async init(ctx) { + ctx.hook('test:event', async (data) => { + receivedData = data; + }); + }, + }); + + await kernel.bootstrap(); + await kernel.context.trigger('test:event', { foo: 'bar' }); + + expect(receivedData).toEqual({ foo: 'bar' }); + + await kernel.shutdown(); + }); +}); +``` + +## Best Practices + +1. **Use before* for validation** — Abort operations early +2. **Use after* for side effects** — Notifications, logging, external API calls +3. **Keep hooks fast** — Especially before* hooks +4. **Use try/catch in after* hooks** — Don't let one failure cascade +5. **Use descriptive hook names** — Follow `{namespace}:{event-name}` convention +6. **Document custom hooks** — What they do, what arguments they pass +7. **Don't mutate arguments** — Except for `record` in before* hooks +8. **Test hook handlers** — Verify they execute and handle errors +9. **Limit hook count** — Too many hooks slow down operations +10. **Use specific object names** — Don't hook all objects unless necessary diff --git a/skills/objectstack-plugin/SKILL.md b/skills/objectstack-plugin/SKILL.md index 17628b63b..90ee24862 100644 --- a/skills/objectstack-plugin/SKILL.md +++ b/skills/objectstack-plugin/SKILL.md @@ -46,7 +46,7 @@ For comprehensive documentation with incorrect/correct examples: - **[Plugin Lifecycle](./rules/plugin-lifecycle.md)** — 3-phase lifecycle (init/start/destroy), execution order, complete examples - **[Service Registry](./rules/service-registry.md)** — DI container, factories, lifecycles (singleton/transient/scoped), core fallbacks -- **[Hooks & Events](./rules/hooks-events.md)** — 14 built-in hooks, custom events, handler patterns, performance tips +- **[Hooks & Events](./rules/hooks-events.md)** — Plugin hooks reference (→ [objectstack-hooks](../objectstack-hooks/SKILL.md)) --- @@ -396,7 +396,7 @@ Strategies: `boolean` | `percentage` | `user_list` | `group` | `custom` - [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) — 3-phase lifecycle, dependencies, complete examples - [rules/service-registry.md](./rules/service-registry.md) — DI container, factories, core fallbacks -- [rules/hooks-events.md](./rules/hooks-events.md) — 14 built-in hooks, custom events, patterns +- [rules/hooks-events.md](./rules/hooks-events.md) — Plugin hooks quick reference (→ [objectstack-hooks](../objectstack-hooks/references/plugin-hooks.md)) - [references/kernel/plugin.zod.ts](./references/kernel/plugin.zod.ts) — PluginContext schema, lifecycle hooks - [references/kernel/context.zod.ts](./references/kernel/context.zod.ts) — RuntimeMode, KernelContext - [references/kernel/service-registry.zod.ts](./references/kernel/service-registry.zod.ts) — Service scope types diff --git a/skills/objectstack-plugin/rules/hooks-events.md b/skills/objectstack-plugin/rules/hooks-events.md index c483c6266..b0ddd4885 100644 --- a/skills/objectstack-plugin/rules/hooks-events.md +++ b/skills/objectstack-plugin/rules/hooks-events.md @@ -1,19 +1,40 @@ -# Hook & Event System +# Plugin Hooks & Events (Reference) -Complete guide for using hooks and events in ObjectStack plugins. +> **Note:** This document is a reference pointer. Complete documentation has been moved to the canonical hooks skill. -## Hook Registration +--- + +## Complete Documentation + +For comprehensive plugin hooks and event system documentation, see: + +**→ [objectstack-hooks/references/plugin-hooks.md](../../objectstack-hooks/references/plugin-hooks.md)** + +The canonical reference includes: +- Complete hook registration API (`ctx.hook`, `ctx.trigger`) +- All built-in hooks (kernel lifecycle + data events) +- Custom plugin event patterns +- Hook handler patterns and error handling +- Performance considerations +- Testing strategies +- Best practices + +--- + +## Quick Reference + +### Hook Registration Register hook handlers in `init()` or `start()`: ```typescript async init(ctx: PluginContext) { - // Register a hook handler + // Kernel lifecycle hook ctx.hook('kernel:ready', async () => { - ctx.logger.info('System is ready!'); + ctx.logger.info('System ready'); }); - // Register data lifecycle hooks + // Data lifecycle hook ctx.hook('data:beforeInsert', async (objectName, record) => { if (objectName === 'task') { record.created_at = new Date().toISOString(); @@ -22,168 +43,48 @@ async init(ctx: PluginContext) { } ``` -## Triggering Events - -Trigger custom hooks to notify other plugins: +### Triggering Custom Events ```typescript async start(ctx: PluginContext) { - // Trigger a custom event await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); } ``` -## Built-in Hooks - -### Kernel Lifecycle Hooks - -| Hook | Triggered When | Arguments | -|:-----|:---------------|:----------| -| `kernel:ready` | All plugins started, system validated | (none) | -| `kernel:shutdown` | Shutdown begins | (none) | - -### Data Lifecycle Hooks - -| Hook | Triggered When | Arguments | -|:-----|:---------------|:----------| -| `data:beforeInsert` | Before a record is created | `(objectName, record)` | -| `data:afterInsert` | After a record is created | `(objectName, record, result)` | -| `data:beforeUpdate` | Before a record is updated | `(objectName, id, record)` | -| `data:afterUpdate` | After a record is updated | `(objectName, id, record, result)` | -| `data:beforeDelete` | Before a record is deleted | `(objectName, id)` | -| `data:afterDelete` | After a record is deleted | `(objectName, id, result)` | -| `data:beforeFind` | Before querying records | `(objectName, query)` | -| `data:afterFind` | After querying records | `(objectName, query, result)` | - -### Metadata Hooks - -| Hook | Triggered When | Arguments | -|:-----|:---------------|:----------| -| `metadata:changed` | Metadata is registered or updated | `(type, name, metadata)` | +### Built-in Hooks -## Custom Hooks +**Kernel Lifecycle:** +- `kernel:ready` — All plugins started, system validated +- `kernel:shutdown` — Shutdown begins -Create your own hooks following the convention: `{plugin-namespace}:{event-name}`. +**Data Lifecycle:** +- `data:beforeInsert` — Before record created +- `data:afterInsert` — After record created +- `data:beforeUpdate` — Before record updated +- `data:afterUpdate` — After record updated +- `data:beforeDelete` — Before record deleted +- `data:afterDelete` — After record deleted +- `data:beforeFind` — Before querying records +- `data:afterFind` — After querying records -```typescript -// In your plugin -async start(ctx: PluginContext) { - await ctx.trigger('analytics:pageview', { - path: '/dashboard', - userId: '123', - }); -} - -// In another plugin -async init(ctx: PluginContext) { - ctx.hook('analytics:pageview', async (data) => { - console.log('Page viewed:', data.path); - }); -} -``` +**Metadata:** +- `metadata:changed` — Metadata registered or updated -## Hook Handler Patterns +### Custom Hooks -### Simple Handler +Follow the convention: `{plugin-namespace}:{event-name}` ```typescript -ctx.hook('kernel:ready', async () => { - console.log('System ready'); -}); -``` +// Trigger +await ctx.trigger('analytics:pageview', { path: '/dashboard', userId: '123' }); -### Handler with Data - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) => { - console.log(`Created ${objectName} record:`, result.id); +// Subscribe +ctx.hook('analytics:pageview', async (data) => { + console.log('Page viewed:', data.path); }); ``` -### Handler with Context - -```typescript -ctx.hook('data:beforeInsert', async (objectName, record) => { - // Access kernel context - const user = ctx.getService('auth').getCurrentUser(); - record.created_by = user.id; -}); -``` - -### Async Error Handling - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) => { - try { - await sendNotification(record); - } catch (error) { - ctx.logger.error('Failed to send notification', error); - // Don't throw — let other hooks continue - } -}); -``` - -## Incorrect vs Correct - -### ❌ Incorrect — Blocking Hook with Slow Operation - -```typescript -ctx.hook('data:beforeInsert', async (objectName, record) => { - // ❌ Blocks transaction - await sendEmail(record.email); - await callExternalAPI(record); -}); -``` - -### ✅ Correct — Use after* Hook for Side Effects - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) { - // ✅ Non-blocking, outside transaction - try { - await sendEmail(record.email); - await callExternalAPI(record); - } catch (error) { - ctx.logger.error('Side effect failed', error); - } -}); -``` - -### ❌ Incorrect — Throwing in after* Hook - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) { - throw new Error('Notification failed'); // ❌ Too late to abort -}); -``` - -### ✅ Correct — Logging Errors in after* Hook - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) { - try { - await sendNotification(result); - } catch (error) { - ctx.logger.error('Notification failed', error); // ✅ Log, don't throw - } -}); -``` - -### ❌ Incorrect — Modifying result in before* Hook - -```typescript -ctx.hook('data:beforeInsert', async (objectName, record) => { - record.result = { id: '123' }; // ❌ result doesn't exist yet -}); -``` - -### ✅ Correct — Modifying input in before* Hook - -```typescript -ctx.hook('data:beforeInsert', async (objectName, record) { - record.created_at = new Date().toISOString(); // ✅ Modify input -}); -``` +--- ## Common Patterns @@ -193,7 +94,6 @@ ctx.hook('data:beforeInsert', async (objectName, record) { ctx.hook('data:beforeInsert', async (objectName, record) => { if (objectName === 'task') { record.status = record.status || 'pending'; - record.priority = record.priority || 'medium'; } }); ``` @@ -207,7 +107,6 @@ ctx.hook('data:afterInsert', async (objectName, record, result) => { action: 'create', object: objectName, recordId: result.id, - timestamp: new Date().toISOString(), }); }); ``` @@ -222,136 +121,43 @@ ctx.hook('data:afterUpdate', async (objectName, id, record, result) => { }); ``` -### Cross-Object Updates - -```typescript -ctx.hook('data:afterInsert', async (objectName, record, result) => { - if (objectName === 'invoice_line_item') { - // Update invoice total - const engine = ctx.getService('objectql'); - await engine.object('invoice').update(record.invoice_id, { - updated_at: new Date().toISOString(), - }); - } -}); -``` - -### Validation +--- -```typescript -ctx.hook('data:beforeInsert', async (objectName, record) => { - if (objectName === 'account') { - if (!record.email || !record.email.includes('@')) { - throw new Error('Valid email is required'); - } - } -}); -``` - -## Hook Execution Order - -Hooks are executed in **registration order** within each plugin, then by **plugin initialization order**. - -```typescript -// Plugin A (depends on nothing) -ctx.hook('kernel:ready', () => console.log('A')); - -// Plugin B (depends on A) -ctx.hook('kernel:ready', () => console.log('B')); - -// Output: A, B -``` - -## Performance Considerations - -### before* Hooks -- ⚠️ Block the operation — keep fast -- ⚠️ Run inside transaction — don't call slow APIs -- ✅ Use for validation and data enrichment -- ✅ Throw errors to abort operation - -### after* Hooks -- ⚠️ Still block by default — use sparingly -- ✅ Use for notifications and logging -- ✅ Use try/catch to prevent cascading failures -- ✅ Consider async execution (if supported) - -## Hook Naming Conventions - -Follow the pattern: `{namespace}:{event-name}` +## Best Practices -**Good names:** -- `auth:user-login` -- `sales:opportunity-created` -- `billing:invoice-paid` -- `analytics:event-tracked` +✅ **DO:** +1. Use `before*` for validation +2. Use `after*` for side effects (notifications, logging, external API calls) +3. Keep hooks fast — especially `before*` hooks +4. Use try/catch in `after*` hooks — don't let one failure cascade +5. Follow naming convention: `{namespace}:{event-name}` +6. Test hook handlers thoroughly -**Bad names:** -- `userLogin` (no namespace) -- `auth.user.login` (use colons, not dots) -- `auth:USER_LOGIN` (use lowercase) +❌ **DON'T:** +1. Don't block operations with slow external API calls in `before*` hooks +2. Don't throw in `after*` hooks (use try/catch and log errors) +3. Don't mutate arguments (except `record` in `before*` hooks) +4. Don't create circular dependencies between plugins +5. Don't hook all objects unless necessary -## Testing Hooks +--- -```typescript -import { describe, it, expect } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; -import MyPlugin from './plugin'; - -describe('Hook System', () => { - it('executes hook handler', async () => { - const kernel = new LiteKernel(); - let hookCalled = false; - - kernel.use({ - name: 'test-plugin', - async init(ctx) { - ctx.hook('test:event', async () => { - hookCalled = true; - }); - }, - }); - - await kernel.bootstrap(); - await kernel.context.trigger('test:event'); - - expect(hookCalled).toBe(true); - - await kernel.shutdown(); - }); +## Hook Execution Order - it('passes arguments to hook handler', async () => { - const kernel = new LiteKernel(); - let receivedData: any; +Hooks execute in **registration order** within each plugin, then by **plugin initialization order** (based on dependencies). - kernel.use({ - name: 'test-plugin', - async init(ctx) { - ctx.hook('test:event', async (data) => { - receivedData = data; - }); - }, - }); +--- - await kernel.bootstrap(); - await kernel.context.trigger('test:event', { foo: 'bar' }); +## See Also - expect(receivedData).toEqual({ foo: 'bar' }); +- **[objectstack-hooks/SKILL.md](../../objectstack-hooks/SKILL.md)** — Complete hooks system overview +- **[objectstack-hooks/references/plugin-hooks.md](../../objectstack-hooks/references/plugin-hooks.md)** — Full plugin hooks documentation +- **[objectstack-hooks/references/data-hooks.md](../../objectstack-hooks/references/data-hooks.md)** — Data lifecycle hooks +- **[Plugin Lifecycle](./plugin-lifecycle.md)** — 3-phase plugin lifecycle +- **[Service Registry](./service-registry.md)** — DI container and service management - await kernel.shutdown(); - }); -}); -``` +--- -## Best Practices +**For complete documentation with detailed examples, hook context API, testing strategies, and performance optimization, see the canonical reference:** -1. **Use before* for validation** — Abort operations early -2. **Use after* for side effects** — Notifications, logging, external API calls -3. **Keep hooks fast** — Especially before* hooks -4. **Use try/catch in after* hooks** — Don't let one failure cascade -5. **Use descriptive hook names** — Follow `{namespace}:{event-name}` convention -6. **Document custom hooks** — What they do, what arguments they pass -7. **Don't mutate arguments** — Except for `record` in before* hooks -8. **Test hook handlers** — Verify they execute and handle errors -9. **Limit hook count** — Too many hooks slow down operations -10. **Use specific object names** — Don't hook all objects unless necessary +→ **[objectstack-hooks/references/plugin-hooks.md](../../objectstack-hooks/references/plugin-hooks.md)** diff --git a/skills/objectstack-schema/SKILL.md b/skills/objectstack-schema/SKILL.md index f7a3dce04..0972aacb1 100644 --- a/skills/objectstack-schema/SKILL.md +++ b/skills/objectstack-schema/SKILL.md @@ -101,7 +101,7 @@ For comprehensive documentation with incorrect/correct examples: - **[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)** — Data lifecycle hooks, before/after patterns, side effects +- **[Lifecycle Hooks](./rules/hooks.md)** — Data lifecycle hooks reference (→ [objectstack-hooks](../objectstack-hooks/SKILL.md)) --- @@ -254,7 +254,7 @@ const accountHook: Hook = { export default accountHook; ``` -See [rules/hooks.md](./rules/hooks.md) for all 14 lifecycle events and patterns. +See [rules/hooks.md](./rules/hooks.md) for quick reference, or [objectstack-hooks](../objectstack-hooks/SKILL.md) for complete documentation of all 14 lifecycle events and patterns. --- @@ -297,7 +297,7 @@ When extending an object you do not own: - [rules/relationships.md](./rules/relationships.md) — lookup vs master_detail, patterns - [rules/validation.md](./rules/validation.md) — All validation types, script inversion - [rules/indexing.md](./rules/indexing.md) — Index types, composite/partial strategies -- [rules/hooks.md](./rules/hooks.md) — Data lifecycle hooks, 14 events, patterns +- [rules/hooks.md](./rules/hooks.md) — Data lifecycle hooks quick reference (→ [objectstack-hooks](../objectstack-hooks/references/data-hooks.md)) - [references/data/field.zod.ts](./references/data/field.zod.ts) — FieldType enum, FieldSchema - [references/data/object.zod.ts](./references/data/object.zod.ts) — ObjectSchema, capabilities - [references/data/validation.zod.ts](./references/data/validation.zod.ts) — Validation rule types diff --git a/skills/objectstack-schema/rules/hooks.md b/skills/objectstack-schema/rules/hooks.md index bb503e1d6..5a62d8173 100644 --- a/skills/objectstack-schema/rules/hooks.md +++ b/skills/objectstack-schema/rules/hooks.md @@ -1,1038 +1,145 @@ ---- -name: objectstack-hooks -description: > - Write ObjectStack data lifecycle hooks for third-party plugins and applications. - Use when implementing business logic, validation, side effects, or data transformations - during CRUD operations. Covers hook registration, handler patterns, context API, - error handling, async execution, and integration with the ObjectQL engine. -license: Apache-2.0 -compatibility: Requires @objectstack/spec v4+, @objectstack/objectql v4+ -metadata: - author: objectstack-ai - version: "1.0" - domain: hooks - tags: hooks, lifecycle, validation, business-logic, side-effects, data-enrichment ---- - -# Writing Hooks — ObjectStack Data Lifecycle +# Data Lifecycle Hooks (Reference) -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. +> **Note:** This document is a reference pointer. Complete documentation has been moved to the canonical hooks skill. --- -## 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. - ---- +## Complete Documentation -## Core Concepts +For comprehensive data lifecycle hooks documentation, see: -### What Are Hooks? +**→ [objectstack-hooks/references/data-hooks.md](../../objectstack-hooks/references/data-hooks.md)** -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) | +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 --- -## Hook Definition Schema +## Quick Reference -Every hook must conform to the `HookSchema`: +### Hook Definition ```typescript 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) +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 }, - - // 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, - }, + priority: 100, // Optional: execution order + async: false, // Optional: background execution (after* only) + condition: "status = 'active'", // Optional: conditional execution }; ``` -### Key Properties Explained - -#### `object` — Target Scope - -```typescript -// Single object -object: 'account' - -// Multiple objects -object: ['account', 'contact', 'lead'] - -// All objects (use sparingly — performance impact) -object: '*' -``` - -#### `events` — Lifecycle Events - -```typescript -// 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): - ```typescript - handler: async (ctx: HookContext) => { - if (!ctx.input.email) { - throw new Error('Email is required'); - } - } - ``` - -2. **String references** (for registered handlers): - ```typescript - handler: 'my_plugin.validateAccount' - ``` - -#### `priority` — Execution Order - -Lower numbers execute first: - -```typescript -// 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: - -```typescript -// 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: - -```typescript -// 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 - -```typescript -// 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 - -```typescript -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; // Operation parameters (MUTABLE) - result?: unknown; // Operation result (MUTABLE, after* only) - previous?: Record; // 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:** -```typescript -// beforeInsert, afterInsert -{ - // All field values being inserted - name: 'Acme Corp', - industry: 'Technology', - annual_revenue: 5000000, - ... -} -``` - -**Update operations:** -```typescript -// beforeUpdate, afterUpdate -{ - id: '123', // Record ID being updated - // Only fields being changed - status: 'active', - updated_at: '2026-04-13T10:00:00Z', -} -``` - -**Delete operations:** -```typescript -// beforeDelete, afterDelete -{ - id: '123', // Record ID being deleted -} -``` - -**Query operations:** -```typescript -// 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: - -```typescript -// 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: - -```typescript -// 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: - -```typescript -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 - -```typescript -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 - -```typescript -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 - -```typescript -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 - -```typescript -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 - -```typescript -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; +### 14 Lifecycle Events - 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 - -```typescript -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 - -```typescript -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 - -```typescript -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 - -```typescript -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 - }, -}; -``` +| 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 | -### 10. Data Masking (Read Operations) +### Common Patterns -```typescript -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'); +See the full documentation for complete examples of: - 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); - } - } - }, -}; -``` +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 Methods +## Registration -### Method 1: Declarative (Stack Definition) +Three methods available: -**Best for:** Application-level hooks defined in metadata. +### 1. Declarative (in Stack) ```typescript // objectstack.config.ts -import { defineStack } from '@objectstack/spec'; -import taskHook from './objects/task.hook'; - export default defineStack({ - manifest: { /* ... */ }, - objects: [/* ... */], - hooks: [taskHook], // Register hooks here + hooks: [accountHook, contactHook], }); ``` -### Method 2: Programmatic (Runtime) - -**Best for:** Plugin-provided hooks, dynamic registration. +### 2. Programmatic (in Plugin) ```typescript -// In your plugin's onEnable() -export const onEnable = async (ctx: { ql: ObjectQL }) => { - ctx.ql.registerHook('beforeInsert', async (hookCtx) => { - // Handler logic - }, { - object: 'account', - priority: 100, - }); -}; +ctx.ql.registerHook('beforeInsert', async (hookCtx) => { + // Handler logic +}, { object: 'account', priority: 100 }); ``` -### Method 3: Hook Files (Convention) - -**Best for:** Organized codebases, per-object hooks. +### 3. Hook Files (Convention) ```typescript // src/objects/account.hook.ts -import { Hook, HookContext } from '@objectstack/spec/data'; - -const accountHook: Hook = { +export default { 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) - -```typescript -handler: async (ctx) => { - if (!ctx.input.email) { - // Aborts operation, rolls back transaction - throw new Error('Email is required'); - } -} -``` - -### Logging Errors (Continue) - -```typescript -{ - 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 - -```typescript -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 - -```typescript -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 = { - 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 = { - object: 'account', - event: 'beforeInsert', - input: { website: 'invalid-url' }, - }; - - await expect( - accountHook.handler(ctx as HookContext) - ).rejects.toThrow('Website must start with http'); - }); -}); -``` - -### Integration Testing - -```typescript -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 - -```typescript -// ❌ 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 - -```typescript -// 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 - -```typescript -// 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 - -```typescript -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 - }, + handler: async (ctx) => { /* ... */ }, }; ``` --- -## 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 +## Best Practices -**Issue:** Performance degradation +✅ **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 -**Solutions:** -1. Use `async: true` for non-critical operations -2. Add `condition` to filter executions -3. Reduce number of global (`object: '*'`) hooks +❌ **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 --- -## References +## See Also -- [hook.zod.ts](./references/data/hook.zod.ts) — Hook schema definition, HookContext interface -- [Examples: app-todo](../../examples/app-todo/src/objects/task.hook.ts) — Simple task hook -- [Examples: app-crm](../../examples/app-crm/src/objects/) — Advanced CRM hooks +- **[objectstack-hooks/SKILL.md](../../objectstack-hooks/SKILL.md)** — Complete hooks system overview +- **[objectstack-hooks/references/data-hooks.md](../../objectstack-hooks/references/data-hooks.md)** — Full data hooks documentation +- **[objectstack-hooks/references/plugin-hooks.md](../../objectstack-hooks/references/plugin-hooks.md)** — Plugin hook system +- **[objectstack-automation](../../objectstack-automation/SKILL.md)** — Flows and Workflows for advanced automation --- -## 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 complete documentation with detailed examples, context API reference, testing strategies, and performance optimization, see the canonical reference:** -For more advanced patterns, see the **objectstack-automation** skill for Flows and Workflows. +→ **[objectstack-hooks/references/data-hooks.md](../../objectstack-hooks/references/data-hooks.md)**