Source-agnostic business-event execution framework. It's the successor to
@hopdrive/hasura-event-detector. Hasura becomes one source adapter among many
(webhooks, cron, and more) instead of the center of the architecture. You name business
events, declare the jobs they run, and the runtime detects them, runs them, observes
them, and (optionally) makes them durable.
Status: built, shipping soon. The runtime detects and runs jobs end to end for Hasura DB triggers (
hasuraEvent), scheduled triggers (hasuraCron), vendor webhooks (webhook), and Hasura Actions (hasuraAction), with the built-in plugins, the platform adapters, and the request/response capability. Not on npm yet. The design record lives indocs/planning/. A few items below are marked planned, meaning specified but not built yet.
npm install hopdrive-eventkitNot published to npm yet (see status above) — the command above is how you'll install it once it ships. Until then, consume it via a local file link / workspace.
Dual ESM/CJS, TypeScript types included. Import the family barrels —
eventkit/sources, /platforms, /plugins — for clean, few-line imports;
the package is sideEffects-free, so a function only bundles the sources/plugins it
actually names. (Granular subpaths like /plugins/observability remain available for the
tightest possible bundle.)
import { createEventKit, defineEvent, job } from 'hopdrive-eventkit';
import { hasuraEvent } from 'hopdrive-eventkit/sources';
import { netlifyPlatform } from 'hopdrive-eventkit/platforms';
import { observability, graphqlSink } from 'hopdrive-eventkit/plugins';
import { sendOfferSMS, notifyOrg } from './jobs';
import { initSdk } from './lib/sdk';
// 1. An event module is DECLARATIVE: a detector + the jobs that run when it fires.
const appointmentReady = defineEvent({
name: 'appointment.ready',
detector: hasuraEvent.detector((ctx) => {
switch (ctx.operation) {
case 'INSERT': {
const insertedReady = ctx.newRow?.status === 'ready';
return insertedReady;
}
case 'UPDATE': {
const becameReady = ctx.columnChanged('status') && ctx.newRow?.status === 'ready';
return becameReady;
}
default:
return false;
}
}),
// runs once before the jobs; merged into every job's ctx.input
prepare: (ctx) => ({ sdk: initSdk(), appointment: ctx.newRow }),
// a STATIC list, the runtime runs them. no handler body.
// a bare function IS a job; wrap with job() only to pass options.
jobs: [sendOfferSMS, job(notifyOrg, { retries: 3 })],
});
// 2. Build the kit ONCE at module scope; register the source (positional) + plugins.
const kit = createEventKit(hasuraEvent)
.use(netlifyPlatform)
.use(observability, { sink: graphqlSink({ endpoint: process.env.GQL_URL, adminSecret: process.env.GQL_SECRET }) })
.registerEvents([appointmentReady]);
// 3. The platform adapter owns the (event, context) signature and the response.
export const handler = kit.handler();A job is a plain function of one context:
export async function sendOfferSMS(ctx) {
const { sdk, appointment } = ctx.input; // shared (from prepare) + per-job input
ctx.log.info('sending offer SMS', { appointmentId: appointment.id });
return sdk.sms.send(/* … */); // return value is recorded as the job output
}EventKit answers the same three questions for every inbound signal, regardless of source:
| Question | Becomes |
|---|---|
| What came into the system? | a normalized EventEnvelope |
| What business event did it represent? | a named DetectedEvent (your detector) |
| What work ran because of it? | a recorded JobExecution per job |
Those three answers are the entire runtime data model. Observability, durability, and flow tooling all build on top of them.
A module is a declarative record. There's no handler function. You declare the jobs and the runtime runs them.
hasuraEvent.defineEvent<Row>({ // the source-scoped builder types every inline (ctx) => seam
name, // business event name (the identity)
detector, // (ctx) => boolean. the predicate, keep the switch house style
prepare?, // (ctx) => shared. runs once, merges into every job's input
jobs?, // a STATIC list of bare functions (or job(fn, opts) when you need options)
run?, // RunOptions for the batch (timeoutMs / metadata). jobs always run parallel;
// mode:'series' + continueOnFailure are a planned future control (ADR-031)
metadata?, // registration-time hints for tooling
}); // a module must declare jobs — the HTTP reply is NOT a module concern
// The endpoint's ONE reply is declared at the INVOCATION layer (ADR-026, re-amended):
// kit.handler({
// before?, // pre-dispatch gate (auth / method); returning a value short-circuits
// after?, // the reply declaration:
// // { body } a constant; the work can't change it
// // { fromResults: (result) => output } business logic over the typed
// // rollup (InvocationResult: every EventOutcome + JobExecution)
// // + optional status/headers (web-standard ResponseInit, as data)
// })
// "202 now, then the work" is a PLATFORM choice (netlifyBackgroundPlatform), not an after mode.Three rules keep every event chain deterministic and analyzable:
- No conditional job inclusion.
jobsis a static literal, so there's no handler body to branch in. A condition either defines a different event (put it in adetectorand give it a name) or means a job has nothing to do (short-circuit at the top of the job on its ownctx.input). - No fan-out. The job set is fixed. Data-driven multiplicity goes inside a job, or via DB writes that trigger further events. Never N emitted jobs.
- No inter-job dependencies. Sibling jobs are mutually ignorant of each other's existence, result, order, and input. A job behaves the same no matter which siblings exist.
prepare is where shared, request-scoped references go (an initialized sdk, a fetched
row, helper closures). Input merges lowest to highest: plugin baselines, then prepare,
then per-job input.
Module prepare only reaches one event's jobs. When several events (and their detectors)
on a function need the same live dependency built once per request — a GraphQL
executor, an authenticated vendor client, a resolved tenant config — declare a kit-level
prepare as a reserved key on createEventKit's config:
import { createFetchExecutor } from '@hopdrive/sdk-core';
const kit = createEventKit(hasuraEvent, {
// runs ONCE per invocation (after normalize, before detection)
prepare: () => ({ executor: createFetchExecutor({ url, adminSecret }) }),
})
.use(netlifyV2Platform)
.registerEvents(events);
// available as the SAME instance on every detector, module prepare, and job:
// detector: (ctx) => ctx.provided.executor && ctx.columnChanged('status')
// job: (ctx) => ctx.provided.executor.mutate(UpdateLane, { ... })- Scope: once per invocation →
ctx.providedon every detector, moduleprepare, and job. (Moduleprepareis once per event →ctx.input; per-jobinputis once per job.) - Request-scoped, never serialized. Unlike
envelope.meta(persisted to observability),providedis the right home for a live object. Defaults to{}when unset. - Also runs under
dryRun(so detectors readingctx.providedbehave identically) — keep it cheap. A throw aborts the invocation cleanly (reported as phaseprepare); no jobs run. - Replaces the need to hand-roll an
augmentJobContextplugin for shared refs.
A job in the jobs array is just a function reference, and the runtime wraps it for you.
You only reach for job(fn, { ... }) when you need to override a setting for that one job.
Pass the options as the second argument.
job(fn, {
retries?, // in-process retry attempts (durable/delayed retries are Batch')
timeoutMs?, // per-job deadline; runtime marks it `timed_out`
name?, // stable identity for observability (else fn.name, set it to survive minification)
tags?, // labels
input?, // live data: an object OR a pure (ctx) => object mapper; merges highest
metadata?, // serializable; persisted by Batch, recorded by Observability
});Jobs always run in parallel with isolated failures. A failing job never blocks,
cancels, or skips a sibling (ADR-014). This is fixed, not configurable. The run.mode
('series') switch and continueOnFailure are a planned future control, not enabled in
this release (ADR-031). Series invites the sequential inter-job coupling the declarative
model removes. run today carries timeoutMs / metadata.
There is no public
run(). The runtime runs the declaredjobs(ADR-025).
A source normalizes an inbound signal and supplies the typed context. Exactly one per
kit (the positional arg to createEventKit).
hasuraEvent. Hasura DB event triggers. Detector context:operation(branch on it with aswitch;case 'MANUAL': return falsesuppresses console edits),oldRow/newRow/row,columnChanged()/columnAdded()/columnRemoved(),previousValue()/currentValue(). Authoring helpershasuraEvent.detector<Row>(fn)/hasuraEvent.prepare<Row>(fn).hasuraCron. Hasura scheduled triggers. Context:scheduleName,scheduledAt,payload.webhook. Vendor webhooks ({ vendor, verify, eventTypeHeader, rejectUnverified? }). The context exposessignatureVerified,vendor,eventType,body.verifyruns once (beforenormalize) and annotatessignatureVerified; the detector decides. SetrejectUnverified: true(ADR-030) to instead reject a bad signature with 401 before any module runs. A status-contract vendor (Stripe) declares the reply at the invocation layer —kit.handler({ after: { body } })for a constant receipt, or{ fromResults }with a thrownClientError(status, ...).hasuraAction. A request/response source for Hasura Actions (sourceType:'action', gated by Hasura's permission model). Context:actionName,input,sessionVariables,requestQuery?. The action's work runs as jobs;kit.handler({ after: { fromResults } })returns the output composed from the rollup, and the generic platform adapter you register (netlifyV2Platform/netlifyPlatform/lambdaPlatform) maps it to 2xx. A thrownActionError(message, code?)maps to 4xx{ message, extensions: { code? } }(no dedicated action platform). The bespokeapp-*endpoints get converted to actions over time (see the migration playbook), not migrated.
Generic and config-driven, registered via kit.use(plugin, config?). I/O plugins take an
injected transport seam (sink / store / logger / send). They never read
process.env. The app passes the config.
| Plugin | Subpath | Registers |
|---|---|---|
observability |
/plugins/observability |
{ sink, strict? }. Buffers Invocation→Event→Job, flushes once per invocation |
graphqlSink |
/plugins/observability/graphql-sink |
the built-in observability sink (bulk-upsert to Hasura) |
batch |
/plugins/batch |
{ store, logFlush? }. Durability. requires:['source:hasura'] |
loopGuard |
/plugins/loop-guard |
{ field?, serviceId?, codec? }. Inbound provenance into envelope.meta |
grafana |
/plugins/transports/grafana |
{ logger } (bridge to sdk-server-logger) or { grafana: { endpoint, auth } } (direct Loki) |
sentry |
/plugins/transports/sentry |
{ dsn?, send? }. Forwards onError |
Durability is emergent from registering
batch. There's nodurableflag, and the job stays batch-unaware. A built-ingraphqlBatchJobStoreis planned. Until then, provide your ownstore.update(id, fields). These plugins are generic (ADR-024), so there's no separate@hopdrive/app-eventkitpackage. HopDrive just supplies config presets.
The web UI that renders what observability + graphqlSink write ships as a
component from this package: hopdrive-eventkit/console. You mount it in a small
wrapper you own that passes it your endpoint and auth, so the same published
console runs against any deployment.
npx degit hopdrive/eventkit/console/template my-consoleFull setup (database, Hasura source + relationships, read-only role, wiring the
writer) is in console/docs/getting-started.md.
Map a deployment runtime's invocation, time budget, and response. Register via kit.use;
the source stays the positional arg. Use kit.handler() (adapter owns the signature) or a
thin kit.handle(...).
netlifyPlatform(). Netlify classic ({ statusCode, body })netlifyV2Platform({ maxExecutionMs? }). Netlify v2 / WebRequest→ResponsenetlifyBackgroundPlatform({ maxExecutionMs? }). 15-min background / 202lambdaPlatform(). AWS Lambda
The before hook on kit.handler({ before }) runs pre-dispatch. Return a
platform-agnostic { status, body } to short-circuit (auth or method) and the adapter's
formatRejection shapes it to the runtime's native response.
From eventkit/testing:
fakeSource(opts?). A minimal in-memory source for unit tests.defineFakeEvent(name, detector, jobs, opts?). Assemble a module without a real source.buildDetectorContextFor(...)/buildHandlerContextFor(...). Exercise a detector or job against any source's context.
Modules are declarative. The jobs are a static array with no hidden conditional
branches, so we can read a kit's whole structure without running anything.
kit.describe() hands you that snapshot. The flow subpath and the eventkit-flow CLI
turn it into a committed YAML doc of how events actually flow through the system
(source, event, jobs, declared side effects). You regenerate it with a script, and CI
keeps it honest.
import { toFlowYaml, toFlowGraph, describeKit } from 'hopdrive-eventkit/flow';
toFlowYaml(kit); // → the human-readable committed doc (see docs/flow.example.yaml)
toFlowGraph(kit); // → { nodes, edges } in the FlowNode/FlowEdge manifest vocabulary (React Flow / manifest diffing)
describeKit(kit); // → the raw KitDescriptionThe CLI just introspects the kit you export from a module. No handler runs:
generate writes the doc (or prints it). check regenerates and fails if the committed
file is missing or out of date. Put check in CI and the doc can't drift from the code.
Point --kit at a module that exports an EventKit (like export const kit = createEventKit(...)), and pass --export <name> if it isn't the default or kit export.
This is the structure half of §14 to §16. The generator verifies structure, that's all it does. The meaning half (hand-authored Flow Manifests and Compare Mode) is still phased.
toFlowGraphemits the sameFlowNode/FlowEdgeshape on purpose, so a generated graph can later be diffed against a manifest, or promoted into one.
- Guide.
docs/guide.htmlis the narrative walkthrough (before/after vs.hasura-event-detector, the plugin model, migration patterns) plus a curated API reference. Open it in a browser. - Flow example.
docs/flow.example.yamlis a generated flow doc (whateventkit-flow generateemits). The Console plan that consumes these docs (Expected/Compare modes) lives indocs/planning/console-expected-flows.md. - API reference (generated). Run
npm run docsto builddocs/api/, the exhaustive, every-symbol reference generated from source, so it can't drift. - Design record. The architecture decisions, ADRs, kickoff, decision register, and raw
planning conversations live in
docs/planning/(see itsREADME.mdfor the read order).
npm install
npm run build # dual ESM/CJS + .d.ts
npm run typecheck # strict, no emit
npm run typecheck:contracts # negative-type fixtures (brand / contribution guards)
npm test # vitest
npm run smoke:bundle # D8 gate: every subpath resolves under esbuild
npm run docs # generate docs/api/ via typedocOpen decisions are proceeding on recommended defaults (D19 positional source, D20 qualified capability tokens, D22 lazy plugin instantiation, D6 shadow-mode parity, D7 no compat facade, D8 single package + subpath exports gated on the bundle smoke test). They're flagged for ratification in the design record.
UNLICENSED · © HopDrive