SDK Compatibility
A strategic goal of AnvilBase is to make the Supabase client libraries work drop-in on the data plane, so migrating from Supabase is trivial. This page is the honest compatibility matrix and the connection recipe.
TL;DR
| SDK module | Status | Notes |
|---|---|---|
Database (.from().select()…) | ✅ drop-in | PostgREST-compatible engine |
RPC (.rpc()) | ✅ drop-in | calls Postgres functions |
Storage (.storage.from()…) | ✅ drop-in | S3-compatible, SDK paths supported |
Realtime (.channel()…) | ✅ drop-in | Phoenix protocol matches realtime-js |
Auth (.auth.signInWithPassword()…) | ✅ drop-in (facade) | GoTrue-compatible facade over Better Auth; see caveats below |
So all five SDK modules — data, RPC, storage, realtime, and auth — are drop-in
against the same createClient base URL.
Connecting
The SDK base URL is your host plus /v1/<project_id>; the key is the project’s
anon key:
import { createClient } from '@supabase/supabase-js'
const db = createClient( 'https://anvilbase.example.com/v1/<project_id>', 'anvilbase_anon_<slug>_…',)From here, db.from(...), db.rpc(...), db.storage.from(...), and
db.channel(...) all work as you’d expect from Supabase. See
Connect Your App for TS/Python/Dart/curl
examples of each.
How the data-plane paths line up
The control plane accepts both the native AnvilBase shape and the SDK-composed shape, so the SDK’s internal path construction resolves correctly:
| SDK call | Resolves to |
|---|---|
db.from('t').select() | /v1/<id>/rest/v1/t → the REST→SQL engine |
db.storage.from('b').upload() | /v1/<id>/storage/v1/object/b/... |
db.channel(...).subscribe() | WebSocket to /v1/<id>/realtime/v1/... |
You don’t construct these by hand — the point is they match, so the SDK “just works.”
How auth compatibility works
AnvilBase serves a GoTrue-compatible facade at
/v1/<project_id>/auth/v1/* — the exact paths supabase-js’s auth module
calls. So db.auth.* works unmodified on the same client:
const db = createClient(`${HOST}/v1/${PROJECT_ID}`, ANON_KEY)
await db.auth.signUp({ email, password })const { data } = await db.auth.signInWithPassword({ email, password })// db is now authenticated — the SDK stores the session and sends the// Authorization header on every data/storage/realtime request itself.
await db.auth.signInWithOtp({ email }) // magic-link / email OTPawait db.auth.signInWithOAuth({ provider: "github" }) // 8 providersawait db.auth.signInWithIdToken({ provider: "apple", token }) // native mobileawait db.auth.mfa.enroll({ factorType: "phone", phone }) // SMS second factorawait db.auth.getUser()await db.auth.signOut()Non-SDK clients can hit the facade endpoints directly — e.g. the password grant:
curl -X POST "$HOST/v1/$PROJECT_ID/auth/v1/token?grant_type=password" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"s3cret"}'# → { "access_token": "<jwt>", ... }See Auth → Users & Sessions for the full endpoint list.
Auth compatibility notes. The standard flows are drop-in, and the service-role
supabase.auth.admin.*user-management surface is served too (it requires the project service_role key). OAuth spans eight providers (github,apple,discord,microsoft,signInWithIdToken) is supported for the providers with server-side id-token verification —apple,microsoft(others return400 provider_id_token_not_supported); and MFA supports both TOTP and phone (SMS) factors (mfa.enroll({ factorType: 'phone', phone })). One edge to know: a user created via OTP / magic-link sign-in does not capture signup-timeoptions.datametadata at first verify — password and anonymous signup persist it immediately; for OTP/magic-link, callupdateUser({ data })after the first sign-in. See Auth → Overview.
Other languages
The same model applies to the Supabase clients in other languages — point them at
/v1/<project_id> with the anon key; their auth modules speak the same GoTrue
facade, so sign-in works through the client:
- Python (
supabase-py) —create_client(url, anon_key), thenclient.auth.sign_in_with_password(...). - Dart/Flutter (
supabase_flutter) —Supabase.initialize(url:, anonKey:). - Any HTTP client — it’s plain REST; see Connect Your App → Raw HTTP.
What to watch for
@supabase/ssrserver helpers: configure them for the PKCE flow (flowType: 'pkce') against the/v1/<project_id>base URL.onAuthStateChangeis client-local and fires normally on the SDK’s own sign-in/out/refresh events.- Storage logical buckets are key prefixes; the SDK’s
from('<bucket>')maps cleanly. - Realtime topics follow the
realtime:public:<table>convention; the SDK builds these for you. The native@anvilbase/clientSDK supports broadcast and presence (channel.on('broadcast'/'presence', …),channel.send(),channel.track()/untrack(),channel.presenceState()) in addition topostgres_changes, matching supabase-js.
Automated compatibility harness
AnvilBase ships an automated harness that installs the real
@supabase/supabase-js and exercises all five SDK surfaces end-to-end against a
running stack — it’s the executable version of this compatibility matrix. It
lives in tests/supabase-js-compat/.
| Surface | Capability | Status |
|---|---|---|
| REST | insert / .insert().select().single() | ✅ |
| REST | bulk array insert | ✅ |
| REST | select + chained .eq() filters | ✅ |
| REST | nested embed (authors→books) | ✅ |
| REST | count=‘exact’ (+ head:true HEAD) | ✅ |
| Auth | signUp | ✅ |
| Auth | signInWithPassword | ✅ |
| Auth | getUser | ✅ |
| Auth | signInWithOtp (magic-link/OTP) | ✅ |
| Auth | admin.createUser/listUsers/getUserById/deleteUser | ✅ |
| Auth | admin isolation (anon → 401/403) | ✅ |
| Auth | signInWithIdToken (id_token grant) | ✅ |
| Storage | createBucket / listBuckets | ✅ |
| Storage | upload / list / getPublicUrl / createSignedUrl | ✅ |
| Storage | copy / move | ✅ |
| Storage | bulk .remove([…]) | ✅ |
| Realtime | server-side POST /api/broadcast receive | ⚠️ skip |
| Realtime | postgres_changes (INSERT event) | ⚠️ skip |
| Realtime | presence track()/presenceState() | ⚠️ skip |
| Realtime | client channel.send broadcast (self) | ⚠️ skip |
| Functions | invoke() (verify_jwt=true) | ✅ |
| Functions | public invoke (verify_jwt=false) | ✅ |
✅ asserted green by the real
@supabase/supabase-jsSDK · ⚠️ probe skipped (stack predates the backing fix) · ❌ not exercised / failing. This table is generated bytests/supabase-js-compat/gen-matrix.tsfrom the harness’s actual result set — do not edit by hand.
Run it against any stack:
cd tests/supabase-js-compatbun installexport ANVILBASE_URL=http://localhost:39001export ANVILBASE_ADMIN_TOKEN=<your ANVILBASE_ADMIN_TOKEN> # auto-provisions a projectbun test --timeout 60000Or against an existing project with ANVILBASE_PROJECT_ID + ANVILBASE_ANON_KEY.
In CI it runs as a labeled (ci:compat) and nightly lane that brings up an
isolated compose stack. See the harness README.md for details.
Separately, AnvilBase’s own native client SDKs (TypeScript, Python, Go, Rust,
Kotlin, Elixir) and the MCP server each have unit suites that run on every
push/PR via the sdks.yml lane; the Swift SDK runs on a macOS runner gated by
the ci:swift label / nightly (macOS minutes are costly). See the
CI lanes table
in the README.
Resolved wire-compatibility fixes (9.8)
The five remaining wire divergences the harness surfaced are now fixed and
asserted hard, so these all behave exactly as @supabase/supabase-js expects:
.insert().select().single()returns a single object (201 Created), not an array — thePOSThonors the single-objectAccept; 0 or >1 affected rows return406 PGRST116, matching PostgREST. The same applies to.update().select().single()/.delete().select().single().- Bulk array insert (
.insert([row1, row2, …])) works — the SDK’s?columns=hint is parsed and used to fix the column set, so heterogeneous / sparse rows insert in a single statement (omitted columns take theirDEFAULT/NULL). countwithhead: true(an HTTPHEAD) returns the count inContent-Rangewith an empty body.- Storage
uploadbyte round-trip —multipart/form-datauploads (what the SDK sends for aFile/Blob) store the inner file bytes, so a laterdownloadis byte-identical. Raw (non-multipart) uploads still round-trip verbatim. - Storage
createSignedUrl— returns a control-plane-routed, client-reachable signed URL (an expiring HMAC token is the credential); fetching it returns the object, for private buckets too. - Storage
.remove()— the SDK’s bulk delete (DELETE /object/{bucket}with a{ prefixes }body) is implemented;storage.from(b).remove([...])works for any number of paths, authorized per key.
Migrating an existing app
If you already have a Supabase app, see
Migrate from Supabase for the step-by-step swap (often
just the client URL/key — supabase.auth.* works unchanged).
Next: Migrate from Supabase.