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 synchronous bootstrap has settled" anchor — use it for reconcile/backfill work that consumes data a later-starting plugin produces during kernel:ready. It does not guarantee app seed data has settled: an inline seed that overruns OS_INLINE_SEED_BUDGET_MS (default 8s) keeps seeding in the background and can outlast even kernel:listening. Subscribe to app:seeded for the true per-app seed-settle point. |
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.
A handler on any of the three boot-path hooks — kernel:ready, kernel:bootstrapped, kernel:listening — that throws fails the boot, on ObjectKernel and LiteKernel alike: the remaining handlers for that hook are skipped, the later boot hooks never fire, bootstrap() rejects with the original error (unwrapped), the kernel is left stopped rather than running, and no "✅ Bootstrap complete" is logged. Everything dispatched before that line is a precondition of it, so swallowing a failure there would not rescue the boot — it would only hide it behind a process reporting success. That is most visible on kernel:listening, where HTTP server plugins open their socket: a listen() that rejects (EACCES on a privileged port, a host that cannot listen at all) takes the boot down instead of leaving a live process with nothing listening.
kernel:ready is still the right place for boot assertions specifically — the service registry is only finished filling by then, so nothing earlier can judge whether a precondition your plugin declared was actually met, and a deployment that cannot honour what it announced must refuse to start rather than serve without the guarantee.
kernel:shutdown is the deliberate exception, on both kernels: a failing shutdown handler is logged (Hook handler failed: kernel:shutdown) and the remaining cleanup still runs, because the handlers queued behind it — and the destroy() pass after them — are what flush buffers and release resources, so aborting teardown only leaks what they were about to release. On ObjectKernel this also means a throwing shutdown handler no longer kills the host process: shutdown() still never rejects, the kernel still ends stopped, and process.exit(1) is reserved for a genuine shutdownTimeout overrun — the one case where teardown really is hung (#5274). Write shutdown handlers that handle their own errors either way.
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 — 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 (a bulk write leaves ctx.input.id undefined and carries the caller's predicate in ctx.input.options.where; the middleware-composed row-scoping AST stays on the engine's internal operation context and is not exposed on ctx.input).
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: find { ast, options }, insert { data, options }, update { id, data, options }, delete { id, options }. 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, positions, accessToken, …). Absent entirely when the call carried no identity envelope. |
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.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' });Error Handling
A hook registered in code with engine.registerHook() always propagates: the engine awaits each handler in turn and never catches, so a throw aborts the operation.
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
}
});Hooks declared as Hook metadata additionally honour an onError policy ('abort' | 'log', default 'abort'). It is a metadata field — registerHook() has no onError option:
onError: 'abort'(default) — the error propagates, rolling back the transaction (when the hook is blocking) and cancelling the operation.onError: 'log'— the error is logged and swallowed; the operation is not cancelled.
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