ObjectStackObjectStack

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 hooksctx.hook(name, handler) / ctx.trigger(name, ...args) on the PluginContext. 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 object Hook metadata or engine.registerHook(). They receive a single HookContext and run on record mutations.

Kernel Lifecycle Hooks

Lifecycle Events

Triggered by the Kernel during bootstrap and shutdown:

EventDescription
kernel:readyAll plugins have successfully started. System is live.
kernel:bootstrappedFired 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:listeningFired after every kernel:ready and kernel:bootstrapped handler has completed (e.g. the HTTP server is accepting connections).
kernel:shutdownShutdown 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:

EventWhen
beforeInsert / afterInsertAround record creation.
beforeUpdate / afterUpdateAround record update — single-id and bulk (multi: true).
beforeDelete / afterDeleteAround 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:

FieldDescription
ctx.objectTarget object name (immutable).
ctx.eventCurrent lifecycle event, e.g. 'beforeInsert' (immutable).
ctx.inputMutable input. Shapes: find { ast, options }, insert { data, options }, update { id, data, options }, delete { id, options }. Modify this to change the operation.
ctx.resultOperation result, available in after* events (mutable).
ctx.previousRecord state before the operation (update/delete).
ctx.sessionAuth/tenancy info (userId, organizationId, positions, accessToken, …). Absent entirely when the call carried no identity envelope.
ctx.apiScoped 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

On this page