Events & Hooks
System-wide event bus for loose coupling between plugins
Events & Hooks
ObjectStack has two distinct hook systems. They look similar but use different APIs, payloads, ordering, and error semantics — pick the right one for the job:
- Kernel lifecycle hooks —
ctx.hook(name, handler)/ctx.trigger(name, ...args)on thePluginContext. Used for system bootstrap events and custom plugin-to-plugin events, matched by exact name. - Data lifecycle hooks — object-level hooks (
beforeInsert,afterUpdate, …) registered via objectHookmetadata orengine.registerHook(). They receive a singleHookContextand run on record mutations.
Kernel Lifecycle Hooks
Lifecycle Events
Triggered by the Kernel during bootstrap and shutdown:
| Event | Description |
|---|---|
kernel:ready | All plugins have successfully started. System is live. |
kernel:bootstrapped | Fired after every kernel:ready handler has settled, before kernel:listening. The "all bootstrap + seed data is ready" anchor — use it for reconcile/backfill work that consumes data a later-starting plugin produces during kernel:ready. |
kernel:listening | Fired after every kernel:ready and kernel:bootstrapped handler has completed (e.g. the HTTP server is accepting connections). |
kernel:shutdown | Shutdown signal received. Plugins should clean up resources. |
Listening to Kernel Events
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System is ready! Sending startup notification...');
});ctx.hook(name, handler) takes exactly two arguments — there is no options/priority parameter. Kernel hooks run in registration order, and ctx.trigger() awaits each handler sequentially.
Emitting Custom Events
Plugins can trigger their own namespaced events for inter-plugin communication. Use ctx.trigger() (it is async and returns a Promise); handlers receive the positional arguments you pass to trigger():
// Trigger a custom business event (await it — trigger returns a Promise)
await ctx.trigger('order:shipped', { orderId: '123', carrier: 'fedex' });
// Another plugin listens — the handler receives the same positional args
ctx.hook('order:shipped', async ({ orderId, carrier }) => {
await sendTrackingEmail(orderId, carrier);
});Kernel hooks are matched by exact name — there is no wildcard or namespace-glob support. A handler registered under 'order:*' will never fire for 'order:shipped'.
Data Lifecycle Hooks
The Data Engine runs hooks around record reads and mutations. Write operations fire before* and after* events:
| Event | When |
|---|---|
beforeInsert / afterInsert | Around record creation. |
beforeUpdate / afterUpdate | Around record update. |
beforeDelete / afterDelete | Around record deletion. |
Read and bulk variants (beforeFind/afterFind, beforeCount, beforeAggregate, beforeUpdateMany, beforeDeleteMany, …) also exist.
The HookContext
Every data hook is a single-argument handler (ctx: HookContext) => void | Promise<void>. The context exposes:
| Field | Description |
|---|---|
ctx.object | Target object name (immutable). |
ctx.event | Current lifecycle event, e.g. 'beforeInsert' (immutable). |
ctx.input | Mutable input. Shapes: insert { doc }, update { id, doc }, delete { id }. Modify this to change the operation. |
ctx.result | Operation result, available in after* events (mutable). |
ctx.previous | Record state before the operation (update/delete). |
ctx.session | Auth/tenancy info (userId, tenantId, roles, …). |
ctx.api | Scoped cross-object data access. |
Registering Data Hooks
// Enrich a record before it is created
engine.registerHook('beforeInsert', async (ctx) => {
if (ctx.object === 'order' && ctx.input.amount > 10000) {
ctx.logger?.warn?.('Large order detected!');
ctx.input.requires_approval = true;
}
}, { object: 'order' });
// React after a record is created
engine.registerHook('afterInsert', async (ctx) => {
await notifyWebhook(ctx.object, ctx.input.id);
}, { object: 'order' });Error Handling
What happens when a data hook throws is governed by the hook's onError policy ('abort' | 'log', default 'abort'):
onError: 'abort'(default) — the error rolls back the transaction (when the hook is blocking), cancelling the operation.onError: 'log'— the error is logged and execution continues; the operation is not cancelled.
engine.registerHook('beforeInsert', async (ctx) => {
if (ctx.object === 'invoice' && !ctx.input.customer_id) {
throw new Error('Invoice must have a customer'); // Aborts the insert (default onError)
}
});Ordering
Data hooks accept a priority option (default 100). They are sorted so that lower priority values run first:
engine.registerHook('beforeInsert', validate, { priority: 50 }); // runs first
engine.registerHook('beforeInsert', enrich, { priority: 100 }); // runs after