AvelonDocs

Guide 04

Drivers

04. Drivers

The rule

Application code never names a vendor. Not in a controller, not in a model, not in a view, not in a test. Auth.user(), never supabase.auth.getUser().

Vendor names appear in exactly two places: avelon.config.ts, and driver packages. Bailiff enforces it, so a stray import { createClient } from '@supabase/supabase-js' anywhere under app/ fails the build.

Everything below exists to make that rule survivable, because the usual way it fails is a lowest-common-denominator abstraction nobody can ship real work on.


Capability map

A driver package implements one or more contracts. @avelon/supabase alone covers six, which is why "install the Supabase driver" stays a one-line setup even though the architecture is pluggable.

ContractOwnsv1Later
databasequeries, writes, migrations, ward compilationSupabase, PostgresMySQL, SQLite, Turso, PlanetScale
identitywho the actor is, sessions, sign in and out, registrationSupabaseClerk, WorkOS, Auth.js, Better Auth
socialOAuth redirect and callbackSupabaseClerk, Auth.js, standalone providers
tokensAPI tokens, abilities, revocationSupabaseClerk, custom JWT
storagefiles, signed URLs, transformsSupabaseS3, R2, GCS, UploadThing
queueenqueue, drain, retry, dead letterSupabase (pgmq)QStash, SQS, Redis, Inngest
mailtransactional send, templatesResendPostmark, SES, SendGrid, SMTP
cacheget, put, tags, lockscontract plus fakeRedis, Upstash, Vercel KV
realtimechannels, presence, broadcastcontract plus fakeSupabase, Pusher, Ably, PartyKit
searchindex, query, facetscontract plus fakeTypesense, Meilisearch, Algolia
paymentscustomers, subscriptions, checkout, webhookscontract plus fakeStripe, Paddle, Lemon Squeezy
notificationspush, SMS, in-appcontract plus fakeTwilio, Expo, Knock
flagsfeature flags, targetingcontract plus fakePostHog, LaunchDarkly, Vercel
logsstructured logs, tracesconsoleAxiom, Sentry, Baselime
ratelimittoken bucket, sliding windowmemoryUpstash, Postgres
aicompletion, embedding, streamingcontract plus fakeOpenAI, Anthropic, Bedrock, Ollama

v1 implements seven contracts and ships fakes for all sixteen. The rest publish as contracts with a fake, so application code can be written against them today and nothing is rewritten when a real driver lands.

That split is deliberate. A published contract is a promise, and a contract changed in v1.1 breaks every third-party driver written against it. Ship few, ship them slowly, and mark the unimplemented ones unstable in their package names until a real driver exists.


Capability negotiation

The hard part of a driver system is that drivers differ. Supabase has no client-side transactions. Clerk has organizations and Supabase does not. Auth.js has no API token issuance.

Two bad answers: a lowest-common-denominator contract nobody can ship on, or a maximal contract where half the methods throw at runtime.

Avelon's answer: a driver declares its capabilities as literal types, and the facade narrows to what the configured driver can actually do. Verified against tsc in strict mode.

type Database<D extends DatabaseDriver> = Pick<D, 'select' | 'insert' | 'raw'> &
  (D['capabilities']['transactions'] extends true ? TransactionSurface : object) &
  (D['capabilities']['rowSecurity'] extends true ? RowSecuritySurface : object)

With @avelon/supabase configured:

await DB.transaction(async (tx) => { ... })
//        ^^^^^^^^^^^ Property 'transaction' does not exist on Database<SupabaseDriver>.
//                    This driver declares transactions: false. Use DB.rpc() instead.

Swap config to @avelon/postgres and the same line compiles. Nothing else in the application changes.

Same shape for auth:

await Auth.organizations()
//         ^^^^^^^^^^^^^ does not exist on Auth<SupabaseIdentityDriver>

Three enforcement layers

Configuration can be environment-driven and types cannot see environment variables, so narrowing alone is not enough.

1. Compile time. Facade narrowing. Covers the case where the driver is chosen statically in avelon.config.ts, which is the overwhelming majority. 2. Boot time. reeve doctor prints the capability matrix for the resolved config and fails on a required capability that is missing. It also diffs the matrix between environments, so "it worked locally" has a named cause. 3. Runtime. supports(driver, 'transactions'), typed against the capability key set so a typo is a compile error.

NotSupportedError is banned

If a driver cannot do something, the method is absent from the type. A runtime "this driver does not support that" is a design failure, not an acceptable outcome. If you want to throw one, the capability map is wrong. Escalate.


Contract rules

Every contract, without exception:

  • Is an interface, never a class. Drivers compose, they do not inherit.
  • Declares capabilities as a literal-typed const.
  • Exposes raw() returning the vendor client, typed to that vendor.
  • Ships a fake that passes the same conformance suite.
  • Supports named instances with one default: Storage.disk('archive'), Mail.mailer('marketing'),

DB.connection('analytics').

  • Normalizes errors into the framework taxonomy.

Error normalization

This is the one people get wrong, and it quietly destroys the abstraction.

If a unique constraint violation arrives as a Supabase error with code 23505, then application code catches 23505, and the vendor has leaked through the error channel while the happy path looks clean. Swapping drivers then breaks every catch block in the codebase.

Every driver maps into a fixed taxonomy:

NotFound  Conflict  Unauthenticated  Forbidden  RateLimited  Invalid  Unavailable  DriverFault

Each carries the original as .cause. Conformance tests assert the mapping by provoking real failures against a live instance, not by checking that a mock returns what the driver author expected.


Writing a driver

reeve make:driver storage r2

Scaffolds a package containing every contract method stubbed with throw new Error('unimplemented'), a capability block with everything false, a wired-up conformance suite, and a README in the house style. The full authoring and certification path is 15-driver-authoring.md.

@avelon/conformance

The load-bearing artifact. A shared test suite per contract. A driver either passes a capability's tests or declares that capability false. Both directions are checked:

  • Declares true, fails the tests: build error.
  • Declares false, but the method is implemented: build error, because someone will call it.
  • Declares true, passes, but the error taxonomy has an unmapped member: build error.

Fakes run the same suite, so a fake cannot drift from the contract. That is what makes Mail.fake() trustworthy in a test rather than decorative.

A third-party driver that passes conformance is listable. That single mechanism is the difference between an ecosystem and a pile of half-working packages, and it costs roughly one afternoon per contract to write.


Configuration

// avelon.config.ts
import { defineConfig } from '@avelon/core'
import { next } from '@avelon/next'
import { supabase } from '@avelon/supabase'
import { resend } from '@avelon/resend'

export default defineConfig({
  adapter: next(),
  drivers: {
    database: supabase({ url: env.SUPABASE_URL, key: env.SUPABASE_ANON_KEY }),
    identity: supabase.identity(),
    social: supabase.social({ providers: ['google', 'github'] }),
    tokens: supabase.tokens(),
    storage: { default: 'uploads', disks: { uploads: supabase.storage('uploads') } },
    queue: supabase.queue(),
    mail: resend({ key: env.RESEND_KEY, from: 'noreply@example.com' }),
  },
} as const)

Explicit imports, no auto-discovery. Tree-shakeable, statically typed, and the as const is what makes capability narrowing work. Per-environment overrides merge on top.


The escape hatch

Every driver exposes raw(), typed to the vendor client.

const supabase = DB.raw() // SupabaseClient, fully typed

Without this the abstraction is a prison and people reject it, correctly. With it unguarded, vendor calls spread and the abstraction becomes decorative. So Bailiff scopes it: raw() may appear in app/Drivers/ or behind an explicit disable comment stating why. Both leave a searchable trail, which is all you actually need.


Fakes

Every contract ships one, and it is not an afterthought.

Mail.fake()
await Mail.assertSent(WelcomeNotice, (m) => m.to === user.email)

Storage.fake()
await Storage.assertStored('avatars/1.png')

Queue.fake()
await Queue.assertPushed(GenerateThumbnail)

Auth.actingAs(user)

Because fakes pass the same conformance suite as real drivers, a test that passes against a fake means something.