Event Handlers

Event Handlers let you run code automatically when something happens in your app. A user signs up, a row changes in the database, a message lands in a queue, or a cron timer fires — your handler runs.

Create an events/ folder in your project, drop in a file, export a handler, and Maravilla wires it up on deploy. No infrastructure, no subscribe/unsubscribe calls, no background process to keep alive.

// events/hello.ts
import { onAuth, onDbChange, onSchedule } from '@maravilla-labs/platform/events';

export const sendWelcome = onAuth({ op: 'registered' }, async (event) => {
  console.log(`Welcome ${event.data?.email}!`);
});

export const auditUsers = onDbChange({ collection: 'users' }, async (event) => {
  console.log(`[${event.op}] user ${event.id}`);
});

export const heartbeat = onSchedule('*/10 * * * * *', async () => {
  console.log('tick');
});

Quick Start

1. Install the events builder. The adapter discovers events/ at build time via @maravilla-labs/functions. Without it, your events/ folder builds silently as a no-op and nothing gets wired into the deployment manifest.

npm install --save-dev @maravilla-labs/functions

Check your build output. Run npm run build and look for a line like ✅ Events build completed: 1 handler in the output, and for an events section in build/manifest.json. If you don’t see either, the dev dependency is missing — the build pipeline silently skips event bundling when @maravilla-labs/functions isn’t resolvable.

2. Create an events/ folder in your project (next to package.json). Or drop a single events.ts at the project root. Split handlers across as many files as you like — every .ts/.js file inside is picked up automatically.

3. Export named handlers using any of the on* helpers. Each exported name becomes the handler’s identity — keep them stable.

4. Deploy. Handlers are discovered at build time, registered with the runtime on first request, and start firing immediately.

// events/kv.ts
import { onKvChange } from '@maravilla-labs/platform/events';

export const logKvChange = onKvChange(
  { namespace: 'demo', keyPattern: 'item:*' },
  async (event) => {
    console.log(`kv ${event.op} on ${event.namespace}/${event.key}`);
  },
);

That’s it. No config, no registration step, no server process to keep running.

Handler Signature

Every handler receives the event payload and a context object. ctx exposes the same platform services you use on the request path (getPlatform()), so you rarely need to reach back into getPlatform() from a handler.

async (event, ctx) => {
  // ── Metadata ────────────────────────────────
  ctx.env;       // per-tenant env vars (Record<string, string>)
  ctx.traceId;   // correlation ID — include in every log line
  ctx.tenant;    // your tenant ID
  ctx.handlerId; // the handler's identity (the exported name)

  // ── Platform services ──────────────────────
  ctx.kv;        // KV store — get / put / delete / list
  ctx.database;  // MongoDB-style document DB — find / insertOne / updateOne / etc.
  ctx.storage;   // object storage — putObject / getObject / deleteObject
  ctx.queue;     // durable queue producer — send(name, payload, opts?)
  ctx.auth;      // auth service — getUser / register / listUsers / updateUser / etc.
  ctx.push;      // Web Push — send notifications to subscribed clients
  ctx.platform;  // full platform object — escape hatch for anything above
}

Version note. The service shortcuts on ctx (kv, database, auth, queue, storage, push) landed in @maravilla-labs/platform@0.2.4. On earlier versions the only shortcut was ctx.env and you had to reach through ctx.platform for everything else — bump to ^0.2.4 before relying on them.

ctx.kv

Same shape as getPlatform().kv. Namespace is the first argument:

const raw = await ctx.kv.get('demo', `user:${id}`);
await ctx.kv.put('demo', `user:${id}`, JSON.stringify(user));
await ctx.kv.delete('demo', `user:${id}`);

ctx.database

Same shape as getPlatform().database. MongoDB-style API:

const id = await ctx.database.insertOne('audit_log', {
  userId: event.userId,
  op: event.op,
  ts: event.ts,
});

const user = await ctx.database.findOne('users', { _id: userId });
await ctx.database.updateOne('users', { _id: userId }, { $set: { lastSeen: Date.now() } });

ctx.auth

Same shape as getPlatform().auth. Useful for looking up the authenticated user inside an onAuth or onDbChange handler:

export const audit = onAuth({}, async (event, ctx) => {
  const user = await ctx.auth.getUser(event.userId);
  await ctx.database.insertOne('audit_log', {
    userId: event.userId,
    email: user?.email,
    op: event.op,
    ts: event.ts,
  });
  await ctx.kv.delete('cache', `user:${event.userId}`);
});

ctx.queue

Fire-and-forget producer — hand off expensive work to a durable queue so the handler returns fast:

await ctx.queue.send('emails', {
  to: event.data?.email,
  subject: 'Welcome!',
}, {
  delayMs: 5000,
  maxAttempts: 5,
});

Available Triggers

TriggerFires whenDurable
onKvChangeA KV key is put, deleted, or expiresNo
onDbChangeA document is created, updated, or deletedNo
onAuthA user registers, logs in, logs out, or is deletedNo
onStorageA file is uploaded (or confirmed) or deletedNo
onQueueA message is enqueued with platform.queue.send()✅ at-least-once
onScheduleA cron expression matches✅ missed ticks catch up
onChannelA message is published to a realtime channelNo
onDeployA new deployment becomes ready (or drains/stops)No
defineEventEscape hatch for matching any platform RenEventNo

Durable triggers persist their work. If your server restarts mid-way, pending queue messages and overdue cron ticks are picked up where they left off.

onKvChange

React to KV Store writes. Filter by namespace, key pattern (glob), and/or operation.

// events/kv.ts
import { onKvChange } from '@maravilla-labs/platform/events';

export const logKvChange = onKvChange(
  { namespace: 'demo', keyPattern: 'item:*', op: 'put' },
  async (event) => {
    // event.op        — 'put' | 'delete' | 'expired'
    // event.namespace — e.g. 'demo'
    // event.key       — the key that changed
    // event.value     — present on 'put'
    // event.ts        — unix ms timestamp
    console.log(`kv ${event.op} on ${event.namespace}/${event.key}`);
  },
);

All filter fields are optional. Omit namespace to match any namespace, omit op to match all three operations, omit keyPattern to match every key.

onDbChange

React to Database mutations on a specific collection.

// events/db.ts
import { onDbChange } from '@maravilla-labs/platform/events';

export const auditUsers = onDbChange(
  { collection: 'users' },
  async (event) => {
    // event.op         — 'insert' | 'update' | 'delete'
    // event.collection — 'users'
    // event.id         — the document ID
    // event.doc        — the current document (insert/update)
    // event.before     — the previous document (update/delete), when available
    // event.after      — the new document (update), when available
    // event.ts
    console.log(`[${event.op}] users/${event.id}`);
  },
);

Scope to one operation with { collection: 'users', op: 'insert' }, or omit op to react to all three.

onAuth

React to Authentication events. Common use: send a welcome email, audit sign-ins, clean up data when a user is deleted.

// events/auth.ts
import { onAuth } from '@maravilla-labs/platform/events';

export const sendWelcome = onAuth({ op: 'registered' }, async (event) => {
  // event.op      — 'registered' | 'logged_in' | 'logged_out'
  //               | 'logged_out_all' | 'deleted' | 'updated'
  // event.userId
  // event.data?.email
  // event.data?.provider  — on OAuth registrations
  // event.data?.profile   — custom fields you passed to register()
  const profile = event.data?.profile ?? {};
  console.log(`welcome ${event.data?.email}, profile=${JSON.stringify(profile)}`);
});

// Audit everything — pass an empty config
export const auditAuth = onAuth({}, async (event) => {
  console.log(`auth.${event.op} user=${event.userId}`);
});

Custom fields you pass to platform.auth.register({ profile: {...} }) come through on event.data.profile, so you can build signup flows that collect extra data (display name, referral code, etc.) and react to them in one place.

onStorage

React to Storage writes and deletes. Filter by key pattern (glob) and/or operation. Every upload path — direct put(), token upload, presigned URL — fires the event automatically.

// events/on-upload.ts
import { onStorage } from '@maravilla-labs/platform/events';

export const onPhotoUpload = onStorage(
  { keyPattern: 'uploads/photos/*', op: 'put' },
  async (event) => {
    // event.op   — 'put' | 'delete'
    // event.key  — the storage key that changed
    // event.data — { size, contentType } on 'put'
    // event.ts   — unix ms timestamp
    console.log(`photo landed: ${event.key} (${event.data?.size} bytes)`);
  },
);

Common pattern: kick off a media transform as soon as the file arrives:

// events/on-video-upload.ts
import { onStorage } from '@maravilla-labs/platform/events';

export const transcodeVideo = onStorage(
  { keyPattern: 'uploads/videos/*', op: 'put' },
  async ({ key, platform }) => {
    await Promise.all([
      platform.media.transforms.transcode(key, { format: 'mp4' }),
      platform.media.transforms.thumbnail(key, { at: '1s', width: 640, format: 'jpg' }),
    ]);
  },
);

If you only need the transform — no custom logic around it — skip the handler entirely and use the declarative transforms block in maravilla.config.ts instead. It compiles into exactly the same onStorage shape at build time.

Omit keyPattern to match every key, omit op to match both put and delete.

onQueue + platform.queue.send

A durable, at-least-once queue. Send a message from anywhere, process it in a handler. Messages survive restarts.

Enqueue:

import { getPlatform } from '@maravilla-labs/platform';

const platform = getPlatform();

await platform.queue.send('emails', {
  to: 'jane@example.com',
  subject: 'Welcome!',
}, {
  delayMs: 5000,   // optional — delay before the message becomes visible
  maxAttempts: 5,  // optional — move to 'dead' after this many failures
});

Handle:

// events/emails.ts
import { onQueue } from '@maravilla-labs/platform/events';

export const sendEmails = onQueue<{ to: string; subject: string }>(
  'emails',
  { batch: 1 },
  async (messages, ctx) => {
    for (const msg of messages) {
      // msg.id         — message ID
      // msg.payload    — your payload
      // msg.attempt    — retry count (starts at 1)
      // msg.enqueuedAt — unix ms
      await sendEmail(msg.payload);
    }
  },
);

The handler receives an array of messages — even when batch: 1, you get a one-element array. Loop over them.

Delivery guarantees:

  • At-least-once — your handler may run more than once for the same message. Make it idempotent.
  • Messages are leased while running. If the handler throws, the message is retried with exponential backoff.
  • After maxAttempts failures (default configured per queue), the message moves to a dead state so you can inspect it instead of retrying forever.
  • Successful handlers ack the message and it’s removed from the queue.

onSchedule

Cron-driven, durable. If your server was down at the scheduled time, the tick fires as soon as it comes back — no missed runs.

// events/cron.ts
import { onSchedule } from '@maravilla-labs/platform/events';

// Every 10 seconds
export const heartbeat = onSchedule('*/10 * * * * *', async (event) => {
  // event.cron        — the cron expression
  // event.scheduledAt — when this tick was due (unix ms)
  // event.firedAt     — when it actually fired
  console.log(`tick scheduled=${event.scheduledAt} fired=${event.firedAt}`);
});

// Every day at 3am
export const nightlyCleanup = onSchedule('0 0 3 * * *', async () => {
  await cleanupOldSessions();
});

Cron expressions use 6 fields: sec min hour day month weekday.

onChannel

React to messages published to a Realtime Channel.

// events/chat.ts
import { onChannel } from '@maravilla-labs/platform/events';

export const onChatMessage = onChannel(
  { channel: 'chat:*', type: 'message' },
  async (event) => {
    // event.channel
    // event.type
    // event.data    — the payload
    // event.uid     — publisher's user ID, if any
    // event.ts
    console.log(`${event.type} on ${event.channel}`);
  },
);

type is optional — omit it to match every message on the channel.

onDeploy

Runs once when a deployment transitions through a lifecycle phase. Use ready for warm-up tasks or one-shot migrations; draining and stopped for graceful shutdown.

// events/deploy.ts
import { onDeploy } from '@maravilla-labs/platform/events';

export const warmup = onDeploy('ready', async (event) => {
  // event.phase — 'ready' | 'draining' | 'stopped'
  // event.ts
  console.log(`deployment ${event.phase}`);
});

defineEvent — Escape Hatch

Everything above is a typed shortcut over the platform’s internal event stream (RenEvent). When a trigger doesn’t cover your case, defineEvent lets you match any event the platform produces by its resource (r), type (t), and/or namespace (ns).

Example — react when a file upload finalizes in storage:

// events/storage.ts
import { defineEvent } from '@maravilla-labs/platform/events';

export const onObjectFinalized = defineEvent(
  { match: { r: 'storage', t: 'object.finalized' } },
  async (event) => {
    // event is the raw RenEvent — see /docs/realtime for the shape
    console.log(`storage finalized: ${event.k}`);
  },
);

See Real-Time Events (REN) for the full list of resources and event types.

Inspecting Handlers

List handlers discovered in your build output:

maravilla events list

Stream live events from a running server — handy during development to see what’s actually firing:

maravilla events tail --url http://localhost:3001

# Filter by resource kind
maravilla events tail --url http://localhost:3001 --kind kv,db

Best Practices

  • Keep handlers small. A handler that does one thing is easier to retry and reason about than one that does five.
  • Make queue handlers idempotent. At-least-once delivery means you may see the same message twice. Check whether the work is already done before doing it again.
  • Don’t block. Handlers run in your app’s worker pool. Long-running work should either be chunked into queue messages or kicked off as a background task.
  • Use stable export names. The export name is the handler’s identity. Renaming it is equivalent to deleting the old handler and creating a new one.
  • Prefer queues for retriable work. If something can fail transiently (network, third-party API), enqueue a message instead of calling the service directly from a synchronous handler.
  • Propagate ctx.traceId. Include it in your log lines so you can correlate a handler run with the request or event that triggered it.

When to Reach for a Workflow Instead

Event handlers are great for quick, synchronous reactions — send an email, update a cache, write an audit row. They’re not the right fit when the reaction spans multiple steps, needs to sleep for hours or days, or has to survive a restart mid-way.

For those cases, use a Workflow. A workflow handler uses the same ctx services, but each step.* call is durably memoized — so the work resumes exactly where it left off if anything goes wrong. You can even start a workflow from an event handler for a clean hand-off:

import { onAuth } from '@maravilla-labs/platform/events';

export const startOnboarding = onAuth({ op: 'registered' }, async (event, ctx) => {
  await ctx.platform.workflows.start('onboarding', {
    userId: event.userId,
    email: event.data?.email,
  });
});

Next Steps