Docs

@avelonjs

supabase

Supabase driver

@avelonjs/supabase

@avelonjs/supabase implements Supabase-backed drivers for Avelon. The database surface compiles QueryIR to PostgREST, declares transactions: false, and synchronizes ward predicates into Postgres row-level security. The identity surface talks to GoTrue over HTTP and binds sessions to request-scoped cookies. Reach for this package when your application targets Supabase and needs portable queries, auth, and database-enforced wards.

Installation

bun add @avelonjs/supabase
export SUPABASE_REST_URL=http://127.0.0.1:3001
export SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
export SUPABASE_DB_URL=postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase
export SUPABASE_AUTH_URL=http://127.0.0.1:54321/auth/v1
export SUPABASE_ANON_KEY=local-anon-key

Basic Usage

import { createSupabaseDatabase } from '@avelonjs/supabase'
import type { QueryIR } from '@avelonjs/core'

const db = createSupabaseDatabase()

const published: QueryIR = {
  table: 'posts',
  mode: 'select',
  select: ['id', 'title'],
  where: [{ kind: 'null', column: 'published_at', negated: true }],
  relations: [],
  order: [{ column: 'published_at', direction: 'desc' }],
  limit: 10,
}

await db.execute(published)

Capabilities

CapabilityValueNotes
transactionsfalsePostgREST has no interactive transactions
rowSecuritytruesyncWards() compiles registered wards to RLS
maxRelationDepth2Measured against live PostgREST relation loading
fullTextSearchfalseNo portable search() surface in v1
upserttrueWildcard upserts are native; explicit update lists use avelon_upsert_subset
returningtrueWrite queries may project rows
windowFunctionsfalseNot available over PostgREST
jsonOperatorstrueInformational

Ward Synchronization

import { createSupabaseBunDatabase } from '@avelonjs/supabase/database/bun'

const db = createSupabaseBunDatabase({
  wards: [
    {
      name: 'posts_owner_read',
      table: 'posts',
      command: 'select',
      using: {
        kind: 'compare',
        column: 'user_id',
        op: '=',
        value: { claim: 'uid' },
      },
    },
  ],
})

await db.syncWards()

{ claim: 'uid' } compiles to auth.uid()::text. A live two-identity denial test in this package proves a cross-tenant read is rejected after sync.

Two entries

Query traffic goes over PostgREST, so @avelonjs/supabase/database carries no bun specifier and runs on a Node runtime. Migrations, fixtures and syncWards() issue DDL, which PostgREST cannot, so they need a Postgres socket. @avelonjs/supabase/database/bun supplies one through Bun's SQL client.

EntryRequest pathMigrations, fixtures, syncWards()
@avelonjs/supabase/databasePostgRESTraise Unavailable
@avelonjs/supabase/database/bunPostgRESTrun over Bun's SQL client

Either way the schema cache the request path validates against comes from PostgREST's own OpenAPI document, filtered by the bearer token the queries use.

Migrations

Migration history uses the same driver-owned SQL pairs as @avelonjs/postgres, applied over the admin connection.

import { createSupabaseBunDatabase } from '@avelonjs/supabase/database/bun'

const db = createSupabaseBunDatabase({
  migrations: [
    {
      id: '20260827_create_posts',
      up: ['CREATE TABLE posts (id text PRIMARY KEY, title text NOT NULL)'],
      down: ['DROP TABLE posts'],
    },
  ],
})

await db.plan()
await db.apply()
await db.status()

Live Conformance

postgrest packages/supabase/postgrest.conf
bun test packages/supabase --max-concurrency=1

Fixture provisioning is owned by this package and reloads the PostgREST schema cache after reset.

Identity

Next on Node must import @avelonjs/supabase/identity. The package root also exports the database driver, which loads Bun SQL.

import { createSupabaseIdentity } from '@avelonjs/supabase/identity'

const auth = createSupabaseIdentity({
  authUrl: process.env.SUPABASE_AUTH_URL,
  apiKey: process.env.SUPABASE_ANON_KEY,
})

export async function currentUser(cookies: import('@avelonjs/core').RequestCookies) {
  return auth(cookies).user()
}

Capabilities: passwords: true, magicLinks: true, oauth: false, organizations: false, mfa: ['totp'], emailVerification: false. After password sign-in you challenge the enrolled TOTP factor and verify the authenticator code. challengeMfa() without an argument uses totp. Email confirmation stays on GoTrue's signup flow and is not an Auth method here.

import { createSupabaseIdentity } from '@avelonjs/supabase'
import type { RequestCookies } from '@avelonjs/core'

export async function completeTotpChallenge(cookies: RequestCookies) {
  const identity = createSupabaseIdentity({
    authUrl: process.env.SUPABASE_AUTH_URL,
    apiKey: process.env.SUPABASE_ANON_KEY,
  })(cookies)

  await identity.signInWithPassword('jordan@example.com', 'correct-horse-battery')
  const challenge = await identity.challengeMfa('totp')
  await identity.verifyMfa(challenge.id, '123456')
}

Social

import { createSupabaseSocial } from '@avelonjs/supabase'

const social = createSupabaseSocial({
  authUrl: process.env.SUPABASE_AUTH_URL,
  apiKey: process.env.SUPABASE_ANON_KEY,
})

export async function githubRedirect(callbackUrl: string): Promise<string> {
  return social.redirect('github', callbackUrl)
}

Providers are the literal list github and google. Undeclared providers raise Invalid. A mismatched or missing OAuth state raises Unauthenticated.

Tokens

Supabase Auth does not issue named API tokens. @avelonjs/supabase stores hashed Signets in Postgres so verify, list, and revoke are durable.

import { createSupabaseTokens } from '@avelonjs/supabase'

const tokens = createSupabaseTokens({
  url: process.env.SUPABASE_DB_URL,
  subject: 'user-1',
})

const issued = await tokens.issue('deployment', { abilities: ['records:read'] })
await tokens.verify(issued.plainText)

Storage

Object bytes persist in Postgres. Signed read URLs are HMAC-scoped HTTP URLs served by the driver process so expiry is real and fetchable.

import { createSupabaseStorage } from '@avelonjs/supabase'

const disk = createSupabaseStorage({ instance: 'default' })
await disk.put('avatars/me.bin', new Uint8Array([1, 2, 3]), {
  contentType: 'application/octet-stream',
})
const url = await disk.signedUrl('avatars/me.bin', 60)

Transforms are undeclared in v1 (D28). Signed uploads and listing are deferred.

Queue

Durable jobs use Postgres FOR UPDATE SKIP LOCKED. This package names pgmq; this environment does not ship that extension, so skip-locked tables carry the same retry, delay, and dead-letter semantics.

import { createSupabaseQueue } from '@avelonjs/supabase'

const queue = createSupabaseQueue()
const id = await queue.enqueue({ name: 'GenerateReport', payload: { reportId: 'report-1' } })
await queue.drain(async (receipt) => {
  if (receipt.id !== id) return
})

Method Reference

MethodSignatureDescription
createSupabaseDatabase(options?: SupabaseDatabaseOptions) => SupabaseDatabaseConstructs the PostgREST database driver.
createSupabaseBunDatabase(options?: SupabaseBunDatabaseOptions) => SupabaseDatabaseSame driver, holding a Bun SQL admin connection for DDL.
SupabaseBunDatabaseOptionsinterfaceSupabaseDatabaseOptions without admin, plus databaseUrl.
loadSchemaCacheFromRest(restUrl: string, headers: Readonly<Record<string, string>>) => Promise<SchemaCache>Reads tables and columns from PostgREST's OpenAPI document.
SupabaseDatabase.execute(query: QueryIR) => Promise<QueryResult>Executes IR as the service role.
SupabaseDatabase.executeAs(token: string, query: QueryIR) => Promise<QueryResult>Executes IR as an arbitrary bearer token.
SupabaseDatabase.rpc(routine: string, args: Readonly<Record<string, unknown>>) => Promise<T>Invokes a PostgREST RPC; missing routines raise Invalid.
SupabaseDatabase.plan() => Promise<MigrationPlan>Returns pending migration identifiers and SQL steps.
SupabaseDatabase.apply() => Promise<readonly MigrationStatus[]>Applies pending migrations over the admin connection.
SupabaseDatabase.rollback(steps?: number) => Promise<readonly MigrationStatus[]>Rolls back the newest applied migration batches.
SupabaseDatabase.status() => Promise<readonly MigrationStatus[]>Lists applied and pending migration states.
SupabaseDatabase.syncWards() => Promise<void>Applies registered ward policies as Postgres RLS.
SupabaseDatabase.resetFixtures() => Promise<void>Recreates assay fixtures, roles, and helper RPCs.
SupabaseDatabase.raw() => { restUrl: string }Returns the REST root at the vendor boundary.
SupabaseDatabase.close() => Promise<void>Closes the direct Postgres admin client.
createSupabaseIdentity(options?: SupabaseIdentityOptions) => (cookies: RequestCookies) => SupabaseIdentityReturns the pinned config-time identity factory.
SupabaseIdentity.user() => Promise<SupabaseActor | null>Returns the current actor from the request cookie session.
SupabaseIdentity.session() => Promise<SupabaseSession | null>Returns the current session or null.
SupabaseIdentity.register(email: string, password: string) => Promise<SupabaseActor>Registers and establishes a session cookie.
SupabaseIdentity.signInWithPassword(email: string, password: string) => Promise<SupabaseActor>Signs in and writes the session cookie.
SupabaseIdentity.signOut() => Promise<void>Ends the Auth session and clears the cookie.
SupabaseIdentity.sendPasswordReset(email: string) => Promise<void>Sends a recovery request without revealing account existence.
SupabaseIdentity.resetPassword(token: string, password: string) => Promise<void>Replaces a password after validating a recovery token.
SupabaseIdentity.updatePassword(password: string) => Promise<void>Changes the current actor's password.
SupabaseIdentity.sendMagicLink(email: string, redirectTo?: string) => Promise<void>Sends a passwordless sign-in link.
SupabaseIdentity.signInWithMagicLink(token: string) => Promise<SupabaseActor>Redeems a link token for a session.
SupabaseIdentity.challengeMfa(factor?: 'totp') => Promise<MfaChallenge<'totp'>>Begins a TOTP challenge for the current session.
SupabaseIdentity.verifyMfa(challengeId: string, code: string) => Promise<void>Verifies a TOTP challenge response.
SupabaseIdentity.raw() => { authUrl: string }Returns the Auth HTTP root at the vendor boundary.
readSessionCookie(cookies: RequestCookies, cookieName: string) => SupabaseSession | nullParses the request-scoped session cookie.
writeSessionCookie(cookies: RequestCookies, cookieName: string, session: SupabaseSession) => voidWrites the session cookie for the current request.
clearSessionCookie(cookies: RequestCookies, cookieName: string) => voidDeletes the session cookie for the current request.
LocalAuthServer.start() => Promise<string>Starts a GoTrue-shaped Auth fixture on an ephemeral port.
LocalAuthServer.reset() => voidClears users, sessions, and recovery tokens.
LocalAuthServer.stop() => Promise<void>Stops the fixture listener.
LocalAuthServer.recoveryToken(email: string) => string | undefinedReturns the recovery token issued for an email.
LocalAuthServer.magicLinkToken(email: string) => string | undefinedReturns the magic-link token issued for an email.
SUPABASE_IDENTITY_ERROR_MAPreadonly { code: string; framework: string; meaning: string }[]GoTrue error codes and the taxonomy member each becomes.
createSupabaseSocial(options?: SupabaseSocialOptions) => SupabaseSocialConstructs the GoTrue social driver.
SupabaseSocial.redirect(provider: string, callbackUrl: string, state?: string) => Promise<string>Builds a GoTrue authorize URL and records CSRF state.
SupabaseSocial.callback(provider: string, params: Readonly<Record<string, string>>, callbackUrl: string) => Promise<SocialIdentity>Verifies state and exchanges the authorization code.
SupabaseSocial.raw() => { authUrl: string }Returns the Auth HTTP root at the vendor boundary.
LocalSocialServer.start() => Promise<string>Starts a GoTrue-shaped token fixture on an ephemeral port.
LocalSocialServer.stop() => Promise<void>Stops the social fixture listener.
createSupabaseTokens(options?: SupabaseTokenOptions) => SupabaseTokensConstructs the Postgres-backed Signet driver.
SupabaseTokens.issue(name: string, options?: TokenIssueOptions) => Promise<IssuedToken>Issues a named token and returns plaintext once.
SupabaseTokens.verify(plainText: string) => Promise<TokenRecord>Authenticates a plaintext token or raises Unauthenticated.
SupabaseTokens.list() => Promise<readonly TokenRecord[]>Lists metadata for the current subject without plaintext.
SupabaseTokens.revoke(id: string) => Promise<void>Revokes one token by stable identifier.
SupabaseTokens.reset() => Promise<void>Recreates the empty signet table.
SupabaseTokens.raw() => SQLReturns the Bun SQL client at the vendor boundary.
SupabaseTokens.close() => Promise<void>Closes the SQL client pool.
createSupabaseStorage(options?: SupabaseStorageOptions) => SupabaseStorageConstructs the Postgres-backed storage driver.
SupabaseStorage.put(path: string, contents: Uint8Array | AsyncIterable<Uint8Array>, options?: { contentType?: string }) => Promise<StorageObject>Stores bytes and returns metadata.
SupabaseStorage.get(path: string) => Promise<Uint8Array>Reads object bytes or raises NotFound.
SupabaseStorage.delete(path: string) => Promise<void>Deletes an object if it exists.
SupabaseStorage.exists(path: string) => Promise<boolean>Reports whether an object exists.
SupabaseStorage.signedUrl(path: string, expiresInSeconds: number) => Promise<string>Creates a fetchable HMAC-signed read URL.
SupabaseStorage.reset() => Promise<void>Recreates the empty object table.
SupabaseStorage.raw() => SQLReturns the Bun SQL client at the vendor boundary.
SupabaseStorage.close() => Promise<void>Stops the signed-URL listener and closes SQL.
createSupabaseQueue(options?: SupabaseQueueOptions) => SupabaseQueueConstructs the skip-locked Postgres queue driver.
SupabaseQueue.enqueue(job: QueueJob) => Promise<string>Enqueues a job for immediate delivery.
SupabaseQueue.enqueueAt(job: QueueJob, availableAt: Date) => Promise<string>Enqueues a job no earlier than the supplied time.
SupabaseQueue.drain(handler: (receipt: QueueReceipt) => Promise<void>, options?: { queue?: string; limit?: number }) => Promise<number>Delivers available jobs and counts attempts.
SupabaseQueue.retry(id: string, delaySeconds?: number) => Promise<void>Releases a failed job, optionally after a delay.
SupabaseQueue.failed(queue?: string) => Promise<readonly FailedQueueJob[]>Lists retained terminal failures.
SupabaseQueue.replay(id: string) => Promise<void>Replays a terminal failure with a fresh attempt budget.
SupabaseQueue.forget(id: string) => Promise<void>Permanently removes a terminal failure.
SupabaseQueue.reset() => Promise<void>Recreates empty job tables.
SupabaseQueue.raw() => SQLReturns the Bun SQL client at the vendor boundary.
SupabaseQueue.close() => Promise<void>Closes the SQL client pool.
compilePostgrest(ir: QueryIR, predicate?: Predicate) => CompiledPostgrestRequestCompiles IR to an HTTP request.
applyPostgrestPredicate(parameters: URLSearchParams, predicate: Predicate) => voidWrites a normalized predicate into PostgREST and= filters.
compileWardPredicate(predicate: Predicate | boolean) => stringCompiles a ward predicate to RLS SQL.
compileWardPolicySql(policy: WardPolicy) => string[]Builds DROP/CREATE POLICY statements for one ward.
compileAllWardPolicies(policies: readonly WardPolicy[]) => string[]Compiles every registered ward into ordered SQL.
mapPostgrestError(status: number, bodyText: string, operation: string) => neverMaps PostgREST failures into framework errors.
resetSupabaseAssayFixtures(sql: SQL) => Promise<void>Provisions empty assay fixtures on a SQL client.
normalizePredicate(predicate: Predicate) => PredicateApplies empty-list and constant identities.
combinedPredicate(ir: Pick<QueryIR, 'where' | 'ward'>) => PredicateANDs where and ward, then normalizes.

Testing

Run the shared database conformance suite against live PostgREST and the package-owned RLS denial test. Run identity and social conformance against a GoTrue-shaped Auth server; when the full Docker stack is unavailable, LocalAuthServer and LocalSocialServer stand in so cookie, password, and OAuth-code flows still exercise the HTTP drivers. Token conformance runs against live Postgres (avelon_signets). Storage conformance runs against live Postgres object bytes plus fetchable signed read URLs. Queue conformance runs against live Postgres skip-locked tables because pgmq is not installed in this environment. Unit tests cover PostgREST compilation without a network dependency.