| title | Events & Hooks |
|---|---|
| description | System-wide event bus for loose coupling between plugins |
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.
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. |
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.
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);
});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 — single-id and bulk (multi: true). |
beforeDelete / afterDelete |
Around record deletion — single-id and bulk (multi: true). |
Reads fire beforeFind / afterFind, for both find and findOne (one read event covers every read shape). There are no per-method (findOne/count/aggregate) or *Many events: read authorization and row filtering are handled by RLS/permission rules, field masking by field-level metadata, and bulk writes by the same before*/after* write events (with the row-scoping predicate in ctx.input.ast).
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 { data }, update { id, data }, 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, organizationId, roles, …). |
ctx.api |
Scoped cross-object data access. |
// Enrich a record before it is created
engine.registerHook('beforeInsert', async (ctx) => {
if (ctx.object === 'order' && ctx.input.data.amount > 10000) {
ctx.input.data.requires_approval = true;
}
}, { object: 'order' });
// React after a record is created — the created record is on ctx.result
engine.registerHook('afterInsert', async (ctx) => {
await notifyWebhook(ctx.object, ctx.result?.id);
}, { object: 'order' });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.data.customer_id) {
throw new Error('Invoice must have a customer'); // Aborts the insert (default onError)
}
});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