mono/packages/ui/docs/deploy/ditch-supabase.md
2026-04-05 19:01:17 +02:00

24 KiB
Raw Blame History

Ditching Supabase — Research & Migration Plan

Status: Research / RFC
Constraints: No Docker. Postgres only. Future Rust/C++ server possible.


1. Current Supabase Usage Audit

1.1 What We Actually Use

Feature Used? Where Migration Effort
Auth (GoTrue) Heavy useAuth.tsx, server/commons/supabase.ts 🔴 High
PostgREST query builder (.from().select()) Heavy ~27 server files, 1 client file (db.ts) 🟡 Medium
Supabase JS client (token get, session mgmt) Heavy ~46 supabase.auth.getSession() calls across client modules 🟡 Medium
OAuth (GitHub, Google) useAuth.tsx 🟡 Medium
Email/Password auth useAuth.tsx 🟡 Medium
Password reset flow useAuth.tsx 🟡 Medium
Token refresh / session persistence supabase/client.ts 🟡 Medium
RLS ⚠️ Enabled on tables Migration SQL files 🟢 Low
Realtime (supabase.channel) Not used (SSE via our server instead)
Storage (supabase.storage) Not used (VFS on our server)
Edge Functions Not used
Supabase CLI / Migrations supabase/migrations/, supabase db push 🟢 Low
Type generation supabase gen typestypes.ts 🟢 Low

1.2 Server-Side Supabase Dependency Map

server/src/commons/supabase.ts — central hub, exports:

  • supabase — service-role client used as a PostgREST query builder
  • getUserCached(token) — validates JWT via supabase.auth.getUser(token)
  • isAdmin(userId) — queries user_roles table via PostgREST
  • flushAuthCache() — in-process auth cache management
  • testSupabaseConnection() — health check

~27 server files import supabase and call .from('table').select/insert/update/delete.

server/src/commons/auth.ts — test auth helper, calls supabase.auth.signInWithPassword.

1.3 Client-Side Supabase Dependency Map

src/integrations/supabase/client.ts — creates the anon-key client (hardcoded URL + key).

src/hooks/useAuth.tsx — the auth context. Uses:

  • supabase.auth.onAuthStateChange — session listener
  • supabase.auth.getSession / getUser
  • supabase.auth.signUp / signInWithPassword / signInWithOAuth / signOut
  • supabase.auth.resetPasswordForEmail / updateUser

src/lib/db.tsgetAuthToken() calls supabase.auth.getSession() to extract the access token, then passes it as Bearer header to our own Hono server.

~46 other client files call supabase.auth.getSession() solely to extract the Bearer token for API calls to our server.

1.4 Key Insight: We Don't Query Supabase From Client

The client never calls supabase.from() for data. All data goes through our Hono API server. The only client-side Supabase usage is:

  1. Auth flows (login/signup/OAuth/session)
  2. Token extraction (to send as Bearer to our Hono server)

This is excellent news — it means the client only needs an auth replacement, not a data layer replacement.


2. Auth Stack Replacement

better-auth.com — TypeScript-first, framework-agnostic auth library.

Why it fits perfectly:

  • Framework-agnostic — works with Hono out of the box
  • First-class Postgres support (Drizzle adapter or raw pg)
  • Email/Password, OAuth (GitHub, Google), 2FA, Passkeys
  • Session management built-in (cookie or Bearer token)
  • No Docker, no external service — runs in your Node.js process
  • React client library for frontend integration
  • Future-proof: auth logic is just Postgres tables + JWT signing — trivially portable to Rust/C++
  • Generates its own DB tables via CLI (npx @better-auth/cli generate)

Hono integration:

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";

export const auth = betterAuth({
    database: drizzleAdapter(db, { provider: "pg" }),
    emailAndPassword: { enabled: true },
    socialProviders: {
        github: { clientId: ENV.GITHUB_CLIENT_ID, clientSecret: ENV.GITHUB_CLIENT_SECRET },
        google: { clientId: ENV.GOOGLE_CLIENT_ID, clientSecret: ENV.GOOGLE_CLIENT_SECRET },
    },
});

// Mount in Hono
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));

Client-side:

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
    baseURL: import.meta.env.VITE_SERVER_URL, // your Hono server
});

// Usage in React — replaces useAuth.tsx
const { data: session } = authClient.useSession();
await authClient.signIn.email({ email, password });
await authClient.signIn.social({ provider: "github" });
await authClient.signOut();

Migration effort for useAuth.tsx:

  • Replace supabase.auth.* calls with authClient.* equivalents
  • Session shape changes: session.user instead of Supabase's User type
  • getAuthToken() in db.ts changes to reading from Better Auth's session (cookie-based or token-based)

2.2 Option B: Auth.js (NextAuth.js v5)

  • Mature, widely used
  • Tightly coupled to Next.js — can work with Hono but requires adapters
  • Session model is different (server-side sessions via DB by default)
  • Not ideal for a future Rust/C++ server migration

2.3 Option C: Custom Auth (DIY)

Roll your own with jose (JWT) + argon2 (password hashing) + Postgres.

  • Maximum control, trivially portable to Rust/C++
  • No dependency on any auth library
  • You own all the security surface area (password reset flows, OAuth dance, CSRF, session rotation)
  • Significant engineering effort to get right

Verdict: Only if you need extreme customization. Better Auth gives you 95% of this with 10% of the effort.

2.4 Option D: SuperTokens (self-hosted)

  • Requires running a "SuperTokens Core" (Java process) — though it can run without Docker
  • Extra process to manage
  • Heavier than Better Auth for your use case

Auth Recommendation: Better Auth

Criteria Better Auth Auth.js DIY SuperTokens
TypeScript-first ⚠️
Hono support native adapter ⚠️
No Docker ⚠️
OAuth (GitHub/Google) 🔨 DIY
Postgres-native
Rust/C++ portability data is standard ⚠️
Maintenance burden Low Low High Medium

3. Database Query Layer: Replacing supabase.from()

3.1 Current Pattern (server)

// All 27+ server files do this:
import { supabase } from '../commons/supabase.js';
const { data, error } = await supabase.from('posts').select('*').eq('user_id', userId);

This uses the Supabase JS client as a PostgREST HTTP client to query the remote Supabase-hosted Postgres over HTTPS.

3.2 Options

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const sql = postgres(process.env.DATABASE_URL!);
export const db = drizzle(sql);

// Query
const posts = await db.select().from(postsTable).where(eq(postsTable.userId, userId));

Pros:

  • TypeScript-first, schemas are TS code
  • Full SQL power — joins, transactions, CTEs, raw SQL escape hatch
  • Direct connection to Postgres (no PostgREST middleman)
  • drizzle-kit pull can introspect your existing schema
  • drizzle-kit generate + drizzle-kit migrate for migrations (replaces supabase db push)
  • Lightweight — no code generation step for types
  • Works with any Postgres (raw, managed, self-hosted)

Cons:

  • API differs from supabase.from().select().eq() syntax — requires rewriting queries
  • Schema definition is manual (but drizzle-kit pull bootstraps it)

B. Kysely (query builder, no ORM)

const posts = await db.selectFrom('posts').where('user_id', '=', userId).selectAll().execute();
  • Closer to raw SQL feel, minimal abstraction
  • Great TypeScript inference
  • Less ecosystem (fewer adapters, plugins)
  • ⚠️ Schema types are inferred from DB, but requires manual or generated type definitions

C. postgrest-js standalone (keep the same API)

npm install @supabase/postgrest-js
import { PostgrestClient } from '@supabase/postgrest-js';
const postgrest = new PostgrestClient('https://your-self-hosted-postgrest:3000');
const { data } = await postgrest.from('posts').select('*').eq('user_id', userId);
  • Zero code changes in your server — same .from().select().eq() API
  • Requires running PostgREST as a separate process (it's a Haskell binary, ~5MB)
  • PostgREST is still an HTTP layer between your server and DB (latency)
  • No transactions, limited join support
  • ⚠️ PostgREST is what Supabase runs under the hood

D. Keep using @supabase/supabase-js (just change the URL)

You can point createClient() at your own PostgREST or even continue using Supabase's Postgres, just swapping out auth.

  • Literally zero code changes to queries
  • Still depends on PostgREST (whether Supabase-hosted or self-hosted)
  • Still coupled to @supabase/supabase-js package
  • ⚠️ Not a real "ditch" — you're just self-hostinga dependency

Query Layer Recommendation: Drizzle ORM

The server has ~27 files with supabase.from() calls. The migration is mechanical:

Supabase PostgREST Drizzle ORM
supabase.from('posts').select('*') db.select().from(posts)
.eq('user_id', id) .where(eq(posts.userId, id))
.insert({ ... }) db.insert(posts).values({ ... })
.update({ ... }).eq('id', id) db.update(posts).set({ ... }).where(eq(posts.id, id))
.delete().eq('id', id) db.delete(posts).where(eq(posts.id, id))
.order('created_at', { ascending: false }) .orderBy(desc(posts.createdAt))
.maybeSingle() .limit(1) + [0] || null

4. RLS (Row-Level Security) — and the @polymech/acl Angle

4.1 Situation: What is currently doing authorization?

Two separate systems run in parallel today:

System A — Supabase RLS (database-layer)

  • Enabled on Postgres tables via migration SQL
  • Policies reference auth.uid() — a Supabase/PostgREST JWT function
  • Currently not the enforcing layer because the client never hits PostgREST directly
  • Will break the moment you swap the auth provider (no auth.uid() without GoTrue)

System B — @polymech/acl (application-layer)

  • The Acl engine from @polymech/acl (MemoryBackend) already runs inside db-acl.ts
  • Drives productAclMiddleware, the /api/acl/* route suite, and VFS permissions
  • Backed by db-acl-db.tsresource_acl + acl_groups + acl_group_members tables
  • Supports: users, groups with inheritance, path-scoped grants, wildcard *
  • Already has 175 passing tests and a published Postgres RLS integration guide

Conclusion: System B is already your real authorization layer. System A (Supabase RLS) is dead weight post-migration.


4.2 What @polymech/acl covers today

server/src/acl/
├── db-acl.ts        ← orchestrator: evaluateAcl(), fetchAclSettings(),
│                      grantAcl(), revokeAcl(), fetchUserEffectiveGroups()
└── db-acl-db.ts     ← IAclBackend: reads/writes resource_acl table via supabase.from()

The evaluateAcl() function in db-acl.ts already:

  1. Resolves virtual roles (anonymous, authenticated, admin)
  2. Loads DB group memberships from acl_group_members (with parent inheritance)
  3. Loads path-scoped grants from AclSettings
  4. Runs @polymech/acl in-memory to check permissions with path-upward walk

The IAclBackend interface (db-acl.ts) makes the storage pluggable — currently two backends:

  • DbAclBackend (default) → resource_acl table via supabase.from()
  • VFS file backend → vfs-settings.json on disk

4.3 The only Supabase coupling in the ACL layer

The ACL system uses supabase.from() in exactly 4 places inside db-acl.ts and db-acl-db.ts:

Location Query
db-acl-db.ts read() supabase.from('resource_acl').select('*').eq(...)
db-acl-db.ts write() supabase.from('resource_acl').delete() + .insert()
db-acl.ts fetchGlobalGroups() supabase.from('acl_groups').select('*')
db-acl.ts putGlobalGroup() supabase.from('acl_groups').upsert/insert
db-acl.ts deleteGlobalGroup() supabase.from('acl_groups').delete()
db-acl.ts fetchGroupMembers() supabase.from('acl_group_members').select(...)
db-acl.ts addUserToGroup() supabase.from('acl_group_members').upsert()
db-acl.ts removeUserFromGroup() supabase.from('acl_group_members').delete()
db-acl.ts fetchUserEffectiveGroups() supabase.from('acl_group_members').select(...) + parent traverse
db-acl.ts isUserAdmin() supabase.from('user_roles').select('role').eq(...)
endpoints/acl.ts handleListUserGroups() supabase.from('acl_group_members').select(...)
endpoints/acl.ts handleListUserEffectiveGroups() supabase.from('acl_groups').select(...) parent traverse
endpoints/acl.ts handleListAclResources() supabase.from('resource_acl').select(...)

All of these are mechanical Drizzle replacements — no logic changes at all.

Also: requireAuthUser() in db-acl.ts calls getUserCached(token) from supabase.ts. After the auth migration, this becomes a Better Auth session lookup.


4.4 @polymech/acl as the RLS replacement for Postgres tables

The ACL package already ships a documented, tested pattern for this in:

packages/acl/docs/postgres-rls-vfs.md

The approach: instead of Supabase's auth.uid() RLS at the DB level, use @polymech/acl at the application layer with Postgres LISTEN/NOTIFY for cache invalidation.

Architecture after migration:

Request → Hono auth middleware (Better Auth JWT)
        → productAclMiddleware → evaluateAcl() → @polymech/acl MemoryBackend
                                                    ↑ loaded from Postgres tables
                                                    ↑ invalidated via LISTEN/NOTIFY
        → Route handler (Drizzle queries, no RLS)

What to do with Supabase RLS policies:

Table Decision Reason
resource_acl Drop RLS ACL engine itself manages access; server is the only writer
acl_groups Drop RLS Admin-only via middleware
acl_group_members Drop RLS Admin-only via middleware
user_roles Drop RLS isUserAdmin() runs server-side with cached result
user_secrets Keep RLS or move to app-layer Sensitive — consider Option B below
profiles Drop RLS Filtered by userId in route handlers
posts, pictures, pages Drop RLS Filtered by userId in route handlers
vfs_index, vfs_mounts Drop RLS VFS uses @polymech/acl AclVfsClient already

Option B for sensitive tables — if you want defense-in-depth on user_secrets:

-- Replace auth.uid() with custom session variable
CREATE POLICY "user_secrets_isolation" ON user_secrets
  USING (user_id = current_setting('app.current_user_id', true)::uuid);

Server sets via Drizzle at start of request:

await db.execute(sql`SET LOCAL app.current_user_id = ${userId}`);

Per packages/acl/docs/postgres-rls-vfs.md — this is the documented Supabase-free RLS pattern.


4.5 Adding Postgres LISTEN/NOTIFY to the ACL cache

The db-acl.ts currently uses appCache with a 5-minute TTL. The ACL package's Postgres guide shows how to replace polling with push invalidation — add a trigger + LISTEN in the existing pg-boss connection:

-- migration: add NOTIFY trigger to acl tables
CREATE OR REPLACE FUNCTION notify_acl_change()
RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('acl_changed', '');
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER acl_groups_notify
  AFTER INSERT OR UPDATE OR DELETE ON acl_groups
  FOR EACH ROW EXECUTE FUNCTION notify_acl_change();

CREATE TRIGGER acl_group_members_notify
  AFTER INSERT OR UPDATE OR DELETE ON acl_group_members
  FOR EACH ROW EXECUTE FUNCTION notify_acl_change();

CREATE TRIGGER resource_acl_notify
  AFTER INSERT OR UPDATE OR DELETE ON resource_acl
  FOR EACH ROW EXECUTE FUNCTION notify_acl_change();
// server/src/acl/acl-listener.ts (new file)
import { Client } from 'pg';
import { appCache } from '@/cache.js';

export async function startAclListener() {
    const listener = new Client({ connectionString: process.env.DATABASE_URL });
    await listener.connect();
    await listener.query('LISTEN acl_changed');
    listener.on('notification', async () => {
        await appCache.flush('acl-*');
        await appCache.flush('user-groups-*');
        await appCache.flush('user-admin-*');
    });
}

This replaces the 5-minute TTL with instant invalidation — same pattern used by the VFS layer.


4.6 Summary: RLS options ranked

Option Effort Safety Rust-portable
A. Drop RLS, rely on @polymech/acl + middleware 🟢 Low Strong (175-test engine)
B. Hybrid — @polymech/acl + SET LOCAL RLS on secrets only 🟡 Medium Defense in depth
C. Keep full Supabase RLS, port to app.current_user_id 🔴 High Defense in depth ⚠️

Recommendation: Option A with LISTEN/NOTIFY invalidation.

@polymech/acl already implements the access control model at the right layer with proper test coverage. Dropping Supabase RLS removes ~50 lines of SQL policy maintenance while the actual enforcement is already handled by the engine you own.


5. Can We Keep Using @supabase/supabase-js?

Short answer: Partially, but it's not worth it.

What the client currently does with it:

  1. Auth → Being replaced entirely (by Better Auth)
  2. Token extraction for API calls → Replaced by Better Auth's session
  3. Type imports (User, Session) → Replace with Better Auth's types
  4. One data query (checkLikeStatus in db.ts) → Move to server API

What the server currently does with it:

  1. Auth validation (getUser(token)) → Replaced by Better Auth
  2. PostgREST queries (.from().select()) → Replaced by Drizzle

Verdict: After migration, there's nothing left to justify the @supabase/supabase-js dependency. Remove it.


6. Migration to Self-Hosted Postgres

6.1 Database Hosting Options (No Docker)

Option Cost Effort Notes
Keep Supabase-hosted Postgres Free tier / $25/mo None Just stop using their JS client. Connect via DATABASE_URL directly.
Neon (serverless Postgres) Free tier Low Great DX, branching, no Docker
Railway $5/mo Low Managed Postgres, simple
Render Free tier Low Managed Postgres
Raw VPS (Hetzner/OVH) ~$5/mo Medium Install Postgres on your server via apt
Any managed Postgres Varies Low AWS RDS, DigitalOcean, etc.

Recommendation: Phase 1 — keep Supabase-hosted Postgres (free tier), just connect via DATABASE_URL. Your data stays where it is. Phase 2 — migrate DB to your VPS or Neon when ready.

6.2 Migrations Tooling Replacement

Current (Supabase CLI) Replacement
npx supabase migration new npx drizzle-kit generate
npx supabase db push npx drizzle-kit migrate
npx supabase gen types Built-in with Drizzle (schema IS the types)
supabase/migrations/*.sql drizzle/*.sql (or keep both during transition)

7. Rust/C++ Server Portability

Auth data portability

Better Auth stores users in standard Postgres tables (user, session, account). A Rust server (e.g., Axum) can:

  • Read sessions from the same session table
  • Validate JWTs using the same BETTER_AUTH_SECRET
  • Use jsonwebtoken crate for JWT validation

Query layer portability

Drizzle schemas are TypeScript — but the underlying Postgres schema is the same. Rust alternatives:

  • SQLx (compile-time checked SQL)
  • Diesel (Rust ORM)
  • SeaORM (async Rust ORM)

The Postgres schema + migrations carry over regardless of ORM.


8. Migration Plan (Phases)

Phase 0: Preparation

  • Add DATABASE_URL to env (Supabase's direct connection string)
  • Install Drizzle: npm install drizzle-orm postgres + npm install -D drizzle-kit
  • Install Better Auth: npm install better-auth
  • Run npx drizzle-kit pull to generate schema from existing DB
  • Run npx @better-auth/cli generate to create auth tables

Phase 1: Auth Migration (Server)

  • Set up Better Auth with Hono (/api/auth/* routes)
  • Create auth middleware that validates Better Auth sessions instead of supabase.auth.getUser()
  • Migrate getUserCached() to use Better Auth's auth.api.getSession()
  • Migrate isAdmin() to query user_roles via Drizzle instead of PostgREST
  • Update loginAdminUser() test helper
  • Keep both auth systems running in parallel during transition

Phase 2: Auth Migration (Client)

  • Replace src/integrations/supabase/client.ts with Better Auth client
  • Rewrite useAuth.tsx using Better Auth React hooks
  • Update getAuthToken() in db.ts to use Better Auth session
  • Update all ~46 files that call supabase.auth.getSession() for token extraction
    • Most of these should already use getAuthToken() from db.ts — centralize the rest
  • Update UpdatePassword.tsx to use Better Auth's password update
  • Test OAuth flows (GitHub, Google) end-to-end

Phase 3: Query Layer Migration (Server)

  • Create server/src/db/index.ts with Drizzle client
  • Create server/src/db/schema.ts from drizzle-kit pull output
  • Migrate server files one module at a time:
    • db-posts.ts → Drizzle
    • db-pictures.ts → Drizzle
    • db-types.ts → Drizzle
    • db-categories.ts → Drizzle
    • db-i18n.ts → Drizzle
    • db-user.ts → Drizzle
    • db-layouts.ts → Drizzle
    • db-search.ts → Drizzle
    • vfs.ts → Drizzle
    • places/db.ts → Drizzle
    • contacts/index.ts → Drizzle
    • campaigns/index.ts → Drizzle
    • ... (remaining files)
  • Each file: replace supabase.from() calls with Drizzle equivalents
  • Run E2E tests after each module migration

Phase 4: Cleanup

  • Remove @supabase/supabase-js from both package.json files
  • Remove src/integrations/supabase/ directory
  • Remove server/src/commons/supabase.ts
  • Drop RLS policies (or rewrite without auth.uid())
  • Remove SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_PUBLISHABLE_KEY from env
  • Update migration tooling to use Drizzle Kit
  • Move checkLikeStatus from client-side Supabase query to server API

Phase 5: Database Migration (Optional, Later)

  • Export data from Supabase Postgres (pg_dump)
  • Import into self-hosted Postgres or Neon
  • Update DATABASE_URL
  • Verify all connections work

9. Risk Assessment

Risk Impact Mitigation
Auth migration breaks login 🔴 High Run both auth systems in parallel. Feature flag.
OAuth redirect URIs change 🟡 Medium Update GitHub/Google OAuth app settings
Existing user sessions invalidated 🟡 Medium Plan a "re-login required" release
Drizzle query bugs 🟡 Medium Migrate one module at a time, run E2E tests
RLS removal exposes data 🟢 Low Your middleware already handles authz
supabase gen types gone 🟢 Low Drizzle schema IS the types

10. Summary

Layer Current Target Effort
Auth Supabase GoTrue + JS client Better Auth (Hono + React) 🔴 High
Query Builder @supabase/supabase-js PostgREST Drizzle ORM (direct pg) 🟡 Medium
Migrations supabase db push Drizzle Kit 🟢 Low
Type Gen supabase gen types Drizzle schema (TS-native) 🟢 Low
RLS Supabase auth.uid() policies Drop (middleware handles authz) 🟢 Low
Database Supabase-hosted Postgres Keep same, migrate later
Realtime Not used N/A
Storage Not used (own VFS) N/A

Total estimated effort: 23 weeks for a full migration, done incrementally.
Biggest single task: Rewriting ~27 server files from PostgREST to Drizzle.
Highest risk: Auth migration — must run both systems in parallel during transition.