AvelonDocs

Guide 15

Writing and certifying a driver

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.md in the house style, already passing reeve docs:check
  • package.json depending on @avelon/core, with @avelon/conformance as a dev dependency
  • src/driver.ts implementing the contract with every capability false or empty
  • src/index.ts exporting the class and a factory
  • tests/<contract>.test.ts calling the shared suite

Contract names:

  • database
  • identity
  • social
  • tokens
  • storage
  • queue
  • mail
  • cache
  • realtime
  • search
  • payments
  • notifications
  • flags
  • logs
  • ratelimit
  • ai

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.
  • capabilities is a const object. Facade narrowing depends on those literals surviving.
  • name is the implementation. instance is the configured disk, mailer, or connection.
  • raw() returns the typed vendor client. any is 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.ts may.

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 --noEmit clean under strict and noUncheckedIndexedAccess
  • reeve bailiff clean
  • reeve docs:check clean
  • No vendor import outside this package and avelon.config.ts