15. Writing and certifying a driver
A driver is how Avelon talks to one vendor without application code ever naming that vendor. You write a class that implements a frozen contract from @avelon/core, declare what it can do as literal capabilities, map failures into the framework error taxonomy, and prove the result with @avelon/conformance. Bailiff then keeps vendor imports inside the driver package and avelon.config.ts.
This guide is the launch path. docs/04-drivers.md is the architecture; this page is the work.
When you write a driver
You write a driver when you need a vendor Avelon does not ship, or when you are replacing a shipped driver in avelon.config.ts. You do not write a driver to call raw() from a controller. That escape hatch lives in app/Drivers/ and leaves a searchable trail; it is not a portable package.
Scaffold
reeve make:driver storage r2
The command writes a package under packages/<slug>/:
README.mdin the house style, already passingreeve docs:checkpackage.jsondepending on@avelon/core, with@avelon/conformanceas a dev dependencysrc/driver.tsimplementing the contract with every capabilityfalseor emptysrc/index.tsexporting the class and a factorytests/<contract>.test.tscalling the shared suite
Contract names:
databaseidentitysocialtokensstoragequeuemailcacherealtimesearchpaymentsnotificationsflagslogsratelimitai
Unimplemented methods throw Error with unimplemented in the message. They do not throw a NotSupportedError. That type is banned: if the driver cannot do the work, the method is absent and the capability stays false.
Implement against the frozen contract
Open the contract in @avelon/core. Do not widen it. Do not cast through unknown to make the class compile. If the contract cannot express what you need, stop and escalate with the method name and why.
Rules that do not move:
- The driver is an interface implementer, never a subclass of another driver.
capabilitiesis aconstobject. Facade narrowing depends on those literals surviving.nameis the implementation.instanceis the configured disk, mailer, or connection.raw()returns the typed vendor client.anyis allowed only at that vendor boundary, with a
comment naming what it bridges. Public signatures stay unknown-free.
- Application code never imports the vendor SDK. Only this package and
avelon.config.tsmay.
Capabilities, both directions
assertCapabilitySurface in @avelon/conformance checks both lies:
1. You declare signedUrls: true and omit signedUrl(). The suite fails. A declared capability must be callable. 2. You implement signedUrl() and leave signedUrls: false. The suite fails. Someone will call it, and it will vanish when they swap drivers.
Flip a capability to true only after the method exists and the suite is green for that surface.
Errors
Catch the vendor failure, throw a taxonomy error, and keep the original as .cause.
NotFound Conflict Unauthenticated Forbidden RateLimited Invalid Unavailable DriverFault
A unique-violation 23505 that leaks into application catch blocks is a failed driver, even when the happy path looks clean. Conformance asserts the mapping by provoking real failures. When you cannot reach the live vendor, say so in the pull request and keep a fixture that still maps codes; do not assert that your own stub returns what you already wrote.
raw() and Bailiff
const client = DB.raw()
Without raw() the abstraction is a prison. Unguarded, vendor calls spread. Bailiff therefore allows raw() in app/Drivers/ or behind an explicit disable comment that states why. A portable driver still exposes raw() so that escape hatch has a typed client to grab.
Certify
A driver is certified when the shared suite for its contract is green against this implementation. The suite lives in @avelon/conformance. You did not write it. That is the point.
import { storageSuite } from '@avelon/conformance/suites'
import { createR2Storage } from './index'
storageSuite({
name: 'r2 storage',
create: () => createR2Storage('uploads'),
})
SuiteContext.create must return a driver in a known-empty state. If the backend is live, you namespace and clean up in cleanup. Set live: true only so the suite can skip assertions a fake cannot make; it never weakens an assertion.
Run:
bun test
bun run typecheck
reeve docs:check
reeve bailiff
bun test green includes the conformance suite. A test that asserts your stub's own return values back to itself proves nothing.
Fakes in @avelon/conformance run the same suites. Mail.fake() is trustworthy because the fake passed mailSuite, not because it looks like mail.
Wire it only in avelon.config.ts. Application folders never import a vendor client.
import { defineConfig } from '@avelon/core'
import { createR2Storage } from '@avelon/r2'
export default defineConfig({
drivers: {
storage: { default: 'uploads', disks: { uploads: createR2Storage('uploads') } },
},
} as const)
Keep the as const. Capability narrowing needs it. Named instances never silently fall back to the default; an unknown disk is Invalid.
Identity is a factory of cookies, not a singleton. The factory is synchronous and takes request-scoped cookies explicitly:
import { defineConfig } from '@avelon/core'
import type { IdentityDriver, RequestCookies } from '@avelon/core'
export function createIdentity(cookies: RequestCookies): IdentityDriver {
return {
name: 'example',
instance: 'default',
capabilities: {
passwords: false,
magicLinks: false,
oauth: false,
organizations: false,
mfa: [],
},
async user() {
const id = cookies.get('user')
return id === undefined || id.length === 0 ? null : { id }
},
async session() {
return null
},
async signOut() {},
raw() {
return cookies
},
}
}
export default defineConfig({
drivers: {
identity: (cookies) => createIdentity(cookies),
},
} as const)
README
reeve docs:check fails the package when a public export is missing from the Method Reference table, a TypeScript code block does not parse, or Installation, Basic Usage, Method Reference, or Testing is absent. The scaffolded README already passes. Keep it passing as you add methods.
Checklist
- Contract methods implemented or absent, never throwing "not supported"
- Capabilities match the method surface in both directions
- Vendor errors map into the taxonomy with
.cause raw()is typed to the vendor client- Shared suite green, including live verification when the work package requires it
tsc --noEmitclean understrictandnoUncheckedIndexedAccessreeve bailiffcleanreeve docs:checkclean- No vendor import outside this package and
avelon.config.ts