# 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 types` → `types.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.ts`** — `getAuthToken()` 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 ### 2.1 Option A: **Better Auth** ⭐ RECOMMENDED [better-auth.com](https://www.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:** ```typescript 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:** ```typescript 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) ```typescript // 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 #### A. **Drizzle ORM** ⭐ RECOMMENDED ```typescript 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) ```typescript 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) ```bash npm install @supabase/postgrest-js ``` ```typescript 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.ts` → `resource_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`: ```sql -- 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: ```typescript 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: ```sql -- 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(); ``` ```typescript // 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:** 2–3 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.