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.
| Contract | Owns | v1 | Later |
|---|---|---|---|
database | queries, writes, migrations, ward compilation | Supabase, Postgres | MySQL, SQLite, Turso, PlanetScale |
identity | who the actor is, sessions, sign in and out, registration | Supabase | Clerk, WorkOS, Auth.js, Better Auth |
social | OAuth redirect and callback | Supabase | Clerk, Auth.js, standalone providers |
tokens | API tokens, abilities, revocation | Supabase | Clerk, custom JWT |
storage | files, signed URLs, transforms | Supabase | S3, R2, GCS, UploadThing |
queue | enqueue, drain, retry, dead letter | Supabase (pgmq) | QStash, SQS, Redis, Inngest |
mail | transactional send, templates | Resend | Postmark, SES, SendGrid, SMTP |
cache | get, put, tags, locks | contract plus fake | Redis, Upstash, Vercel KV |
realtime | channels, presence, broadcast | contract plus fake | Supabase, Pusher, Ably, PartyKit |
search | index, query, facets | contract plus fake | Typesense, Meilisearch, Algolia |
payments | customers, subscriptions, checkout, webhooks | contract plus fake | Stripe, Paddle, Lemon Squeezy |
notifications | push, SMS, in-app | contract plus fake | Twilio, Expo, Knock |
flags | feature flags, targeting | contract plus fake | PostHog, LaunchDarkly, Vercel |
logs | structured logs, traces | console | Axiom, Sentry, Baselime |
ratelimit | token bucket, sliding window | memory | Upstash, Postgres |
ai | completion, embedding, streaming | contract plus fake | OpenAI, 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
capabilitiesas 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.