From cea909427e8c25a7c05ad32955e00e80ca924a25 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Fri, 31 Oct 2025 05:29:06 +1000 Subject: [PATCH 1/4] fix(generic-oauth): `overrideUserInfo` doesn't work (#5683) --- packages/better-auth/src/plugins/generic-oauth/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/better-auth/src/plugins/generic-oauth/index.ts b/packages/better-auth/src/plugins/generic-oauth/index.ts index 76b752c2bb..55ab8796cc 100644 --- a/packages/better-auth/src/plugins/generic-oauth/index.ts +++ b/packages/better-auth/src/plugins/generic-oauth/index.ts @@ -314,7 +314,6 @@ export const genericOAuth = (options: GenericOAuthOptions) => { tokenEndpoint: finalTokenUrl, }); }, - async getUserInfo(tokens) { const userInfo = c.getUserInfo ? await c.getUserInfo(tokens) @@ -334,6 +333,9 @@ export const genericOAuth = (options: GenericOAuthOptions) => { data: userInfo, }; }, + options: { + overrideUserInfoOnSignIn: c.overrideUserInfo, + }, } as OAuthProvider; }); return { From 21ca9530d2ace9a1b9378a31c9dc24cd0110179f Mon Sep 17 00:00:00 2001 From: Rob Bayliss Date: Thu, 30 Oct 2025 19:12:48 -0400 Subject: [PATCH 2/4] fix(sso): Move oauth2-mock-server dep into devDependencies for sso package (#5689) --- packages/sso/package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sso/package.json b/packages/sso/package.json index 462ae465e9..21db4ffc51 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -62,7 +62,6 @@ "@better-fetch/fetch": "catalog:", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", - "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, @@ -73,6 +72,7 @@ "better-call": "catalog:", "body-parser": "^2.2.0", "express": "^5.1.0", + "oauth2-mock-server": "^7.2.1", "tsdown": "catalog:" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b20e1a03f1..7b32434e51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,7 +352,7 @@ importers: version: 12.23.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.0.1(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)) + version: 1.4.2(next@16.0.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -639,7 +639,7 @@ importers: version: 15.8.3(@oramacloud/client@2.1.4)(@tanstack/react-router@1.131.27(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(algoliasearch@5.36.0)(lucide-react@0.542.0(react@19.2.0))(next@16.0.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.1.13) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.0.1(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)) + version: 1.4.2(next@16.0.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -1271,9 +1271,6 @@ importers: jose: specifier: ^6.1.0 version: 6.1.0 - oauth2-mock-server: - specifier: ^7.2.1 - version: 7.2.1 samlify: specifier: ^2.10.1 version: 2.10.1 @@ -1299,6 +1296,9 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + oauth2-mock-server: + specifier: ^7.2.1 + version: 7.2.1 tsdown: specifier: 'catalog:' version: 0.15.11(typescript@5.9.3) @@ -21806,7 +21806,7 @@ snapshots: function-bind@1.1.2: {} - geist@1.4.2(next@16.0.1(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)): + geist@1.4.2(next@16.0.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0)): dependencies: next: 16.0.1(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.90.0) From 1aa4a9ceadee9a9583d373645e5f6dd103656250 Mon Sep 17 00:00:00 2001 From: Musanna Al Akil <43068505+Musanna-al-akil@users.noreply.github.com> Date: Fri, 31 Oct 2025 05:14:14 +0600 Subject: [PATCH 3/4] docs: fix indentation for sendOnSignIn property (#5675) --- docs/content/docs/concepts/email.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/concepts/email.mdx b/docs/content/docs/concepts/email.mdx index 12ef1c682c..2591d51a40 100644 --- a/docs/content/docs/concepts/email.mdx +++ b/docs/content/docs/concepts/email.mdx @@ -80,8 +80,8 @@ export const auth = betterAuth({ subject: "Verify your email address", text: `Click the link to verify your email: ${url}`, }); - sendOnSignIn: true, }, + sendOnSignIn: true, }, emailAndPassword: { requireEmailVerification: true, From e8babdce0a78b0e48d124bdc9312d6407b573098 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:16:13 +0900 Subject: [PATCH 4/4] docs: update migration guide from Supabase Auth (#5669) --- .../docs/guides/supabase-migration-guide.mdx | 943 +++++++++++++++--- 1 file changed, 804 insertions(+), 139 deletions(-) diff --git a/docs/content/docs/guides/supabase-migration-guide.mdx b/docs/content/docs/guides/supabase-migration-guide.mdx index ea7708c2b1..65562972d4 100644 --- a/docs/content/docs/guides/supabase-migration-guide.mdx +++ b/docs/content/docs/guides/supabase-migration-guide.mdx @@ -14,8 +14,8 @@ This migration will invalidate all active sessions. While this guide doesn't cur Before starting the migration process, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started. - + ### Connect to your database @@ -24,10 +24,10 @@ You'll need to connect to your database to migrate the users and accounts. Copy ```package-install npm install pg ``` - And then you can use the following code to connect to your database. ```ts title="auth.ts" +import { betterAuth } from 'better-auth'; import { Pool } from "pg"; export const auth = betterAuth({ @@ -37,13 +37,15 @@ export const auth = betterAuth({ }) ``` + -### Enable Email and Password (Optional) +### Enable Email and Password Enable the email and password in your auth config. ```ts title="auth.ts" -import { admin, anonymous } from "better-auth/plugins"; +import { betterAuth } from 'better-auth'; +import { Pool } from "pg"; export const auth = betterAuth({ database: new Pool({ @@ -61,13 +63,15 @@ export const auth = betterAuth({ }) ``` + ### Setup Social Providers (Optional) -Add social providers you have enabled in your Supabase project in your auth config. +Add all the social providers used in Supabase to the auth config. Missing any may cause user data loss during migration. ```ts title="auth.ts" -import { admin, anonymous } from "better-auth/plugins"; +import { betterAuth } from 'better-auth'; +import { Pool } from "pg"; export const auth = betterAuth({ database: new Pool({ @@ -78,20 +82,23 @@ export const auth = betterAuth({ }, socialProviders: { // [!code highlight] github: { // [!code highlight] - clientId: process.env.GITHUB_CLIENT_ID, // [!code highlight] - clientSecret: process.env.GITHUB_CLIENT_SECRET, // [!code highlight] + clientId: process.env.GITHUB_CLIENT_ID!, // [!code highlight] + clientSecret: process.env.GITHUB_CLIENT_SECRET!, // [!code highlight] } // [!code highlight] } // [!code highlight] }) ``` - -### Add admin and anonymous plugins (Optional) -Add the [admin](/docs/plugins/admin) and [anonymous](/docs/plugins/anonymous) plugins to your auth config. + +### Add admin, anonymous and phoneNumber plugins + +Add the [admin](/docs/plugins/admin), [anonymous](/docs/plugins/anonymous) and [phoneNumber](/docs/plugins/phone-number) plugins to your auth config. To minimize data loss from Supabase Auth during migration, these plugins must be included in the auth config. ```ts title="auth.ts" -import { admin, anonymous } from "better-auth/plugins"; +import { betterAuth } from 'better-auth'; +import { Pool } from "pg"; +import { admin, anonymous, phoneNumber } from 'better-auth/plugins'; export const auth = betterAuth({ database: new Pool({ @@ -106,10 +113,63 @@ export const auth = betterAuth({ clientSecret: process.env.GITHUB_CLIENT_SECRET!, } }, - plugins: [admin(), anonymous()], // [!code highlight] + plugins: [admin(), anonymous(), phoneNumber()], // [!code highlight] }) ``` + + +### Add the additional fields + +To minimize data loss from Supabase Auth, the following additional fields are required. You can adjust them as needed after the migration is complete. + +```ts title="auth.ts" +import { betterAuth } from 'better-auth'; +import { Pool } from "pg"; +import { admin, anonymous, phoneNumber } from 'better-auth/plugins'; + +export const auth = betterAuth({ + database: new Pool({ + connectionString: process.env.DATABASE_URL + }), + emailAndPassword: { + enabled: true, + }, + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + } + }, + plugins: [admin(), anonymous(), phoneNumber()], + user: { // [!code highlight] + additionalFields: { // [!code highlight] + userMetadata: { // [!code highlight] + type: 'json', // [!code highlight] + required: false, // [!code highlight] + input: false, // [!code highlight] + }, // [!code highlight] + appMetadata: { // [!code highlight] + type: 'json', // [!code highlight] + required: false, // [!code highlight] + input: false, // [!code highlight] + }, // [!code highlight] + invitedAt: { // [!code highlight] + type: 'date', // [!code highlight] + required: false, // [!code highlight] + input: false, // [!code highlight] + }, // [!code highlight] + lastSignInAt: { // [!code highlight] + type: 'date', // [!code highlight] + required: false, // [!code highlight] + input: false, // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] + }, // [!code highlight] +}) +``` + + ### Run the migration @@ -119,149 +179,754 @@ Run the migration to create the necessary tables in your database. npx @better-auth/cli migrate ``` -This will create the following tables in your database: - -- [`user`](/docs/concepts/database#user) -- [`account`](/docs/concepts/database#account) -- [`session`](/docs/concepts/database#session) -- [`verification`](/docs/concepts/database#verification) - -This tables will be created on the `public` schema. - - -### Copy the migration script +This will create the necessary tables in the `public` schema of your Better Auth instance. Now that we have the necessary tables in our database, we can run the migration script to migrate the users and accounts from Supabase to Better Auth. -Start by creating a `.ts` file in your project. + -```bash title="Terminal" -touch migration.ts + +### Copy the migration script + +First, set up the environment variables used by the script. + +```dotenv title=".env" +FROM_DATABASE_URL= # Supabase database connection string +TO_DATABASE_URL= # Target Postgres database connection string ``` And then copy and paste the following code into the file. ```ts title="migration.ts" -import { Pool } from "pg"; -import { auth } from "./auth"; -import { User as SupabaseUser } from "@supabase/supabase-js"; +import { generateId } from 'better-auth'; +import { DBFieldAttribute } from 'better-auth/db'; +import { Pool } from 'pg'; +import { auth } from './auth'; // <- Your Better Auth Instance -type User = SupabaseUser & { - is_super_admin: boolean; - raw_user_meta_data: { - avatar_url: string; - }; - encrypted_password: string; - email_confirmed_at: string; - created_at: string; - updated_at: string; - is_anonymous: boolean; - identities: { - provider: string; - identity_data: { - sub: string; - email: string; - }; - created_at: string; - updated_at: string; - }; +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const CONFIG = { + /** + * Number of users to process in each batch + * Higher values = faster migration but more memory usage + * Recommended: 5000-10000 for most cases + */ + batchSize: 5000, + /** + * Resume from a specific user ID (cursor-based pagination) + * Useful for resuming interrupted migrations + * Set to null to start from the beginning + */ + resumeFromId: null as string | null, + /** + * Temporary email domain for phone-only users + * Phone-only users need an email for Better Auth + * Format: {phone_number}@{tempEmailDomain} + */ + tempEmailDomain: 'temp.better-auth.com', }; -const migrateFromSupabase = async () => { - const ctx = await auth.$context; - const db = ctx.options.database as Pool; - const users = await db - .query(` - SELECT - u.*, - COALESCE( - json_agg( - i.* ORDER BY i.id - ) FILTER (WHERE i.id IS NOT NULL), - '[]'::json - ) as identities - FROM auth.users u - LEFT JOIN auth.identities i ON u.id = i.user_id - GROUP BY u.id - `) - .then((res) => res.rows as User[]); - for (const user of users) { - if (!user.email) { - continue; - } - await ctx.adapter - .create({ - model: "user", - data: { - id: user.id, - email: user.email, - name: user.email, - role: user.is_super_admin ? "admin" : user.role, - emailVerified: !!user.email_confirmed_at, - image: user.raw_user_meta_data.avatar_url, - createdAt: new Date(user.created_at), - updatedAt: new Date(user.updated_at), - isAnonymous: user.is_anonymous, - }, - }) - .catch(() => {}); - for (const identity of user.identities) { - const existingAccounts = await ctx.internalAdapter.findAccounts(user.id); +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ - if (identity.provider === "email") { - const hasCredential = existingAccounts.find( - (account) => account.providerId === "credential", - ); - if (!hasCredential) { - await ctx.adapter - .create({ - model: "account", - data: { - userId: user.id, - providerId: "credential", - accountId: user.id, - password: user.encrypted_password, - createdAt: new Date(user.created_at), - updatedAt: new Date(user.updated_at), - }, - }) - .catch(() => {}); - } - } - const supportedProviders = Object.keys(ctx.options.socialProviders || {}) - if (supportedProviders.includes(identity.provider)) { - const hasAccount = existingAccounts.find( - (account) => account.providerId === identity.provider, - ); - if (!hasAccount) { - await ctx.adapter.create({ - model: "account", - data: { - userId: user.id, - providerId: identity.provider, - accountId: identity.identity_data?.sub, - createdAt: new Date(identity.created_at ?? user.created_at), - updatedAt: new Date(identity.updated_at ?? user.updated_at), - }, - }); - } - } - } - } +type MigrationStatus = 'idle' | 'running' | 'paused' | 'completed' | 'failed'; + +type MigrationState = { + status: MigrationStatus; + totalUsers: number; + processedUsers: number; + successCount: number; + failureCount: number; + skipCount: number; + currentBatch: number; + totalBatches: number; + startedAt: Date | null; + completedAt: Date | null; + lastProcessedId: string | null; + errors: Array<{ userId: string; error: string }>; }; -migrateFromSupabase(); + +type UserInsertData = { + id: string; + email: string | null; + name: string; + emailVerified: boolean; + createdAt: string | null; + updatedAt: string | null; + image?: string; + [key: string]: any; +}; + +type AccountInsertData = { + id: string; + userId: string; + providerId: string; + accountId: string; + password: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +type SupabaseIdentityFromDB = { + id: string; + provider_id: string; + user_id: string; + identity_data: Record; + provider: string; + last_sign_in_at: string | null; + created_at: string | null; + updated_at: string | null; + email: string | null; +}; + +type SupabaseUserFromDB = { + instance_id: string | null; + id: string; + aud: string | null; + role: string | null; + email: string | null; + encrypted_password: string | null; + email_confirmed_at: string | null; + invited_at: string | null; + confirmation_token: string | null; + confirmation_sent_at: string | null; + recovery_token: string | null; + recovery_sent_at: string | null; + email_change_token_new: string | null; + email_change: string | null; + email_change_sent_at: string | null; + last_sign_in_at: string | null; + raw_app_meta_data: Record | null; + raw_user_meta_data: Record | null; + is_super_admin: boolean | null; + created_at: string | null; + updated_at: string | null; + phone: string | null; + phone_confirmed_at: string | null; + phone_change: string | null; + phone_change_token: string | null; + phone_change_sent_at: string | null; + confirmed_at: string | null; + email_change_token_current: string | null; + email_change_confirm_status: number | null; + banned_until: string | null; + reauthentication_token: string | null; + reauthentication_sent_at: string | null; + is_sso_user: boolean; + deleted_at: string | null; + is_anonymous: boolean; + identities: SupabaseIdentityFromDB[]; +}; + +// ============================================================================ +// MIGRATION STATE MANAGER +// ============================================================================ + +class MigrationStateManager { + private state: MigrationState = { + status: 'idle', + totalUsers: 0, + processedUsers: 0, + successCount: 0, + failureCount: 0, + skipCount: 0, + currentBatch: 0, + totalBatches: 0, + startedAt: null, + completedAt: null, + lastProcessedId: null, + errors: [], + }; + + start(totalUsers: number, batchSize: number) { + this.state = { + status: 'running', + totalUsers, + processedUsers: 0, + successCount: 0, + failureCount: 0, + skipCount: 0, + currentBatch: 0, + totalBatches: Math.ceil(totalUsers / batchSize), + startedAt: new Date(), + completedAt: null, + lastProcessedId: null, + errors: [], + }; + } + + updateProgress( + processed: number, + success: number, + failure: number, + skip: number, + lastId: string | null, + ) { + this.state.processedUsers += processed; + this.state.successCount += success; + this.state.failureCount += failure; + this.state.skipCount += skip; + this.state.currentBatch++; + if (lastId) { + this.state.lastProcessedId = lastId; + } + } + + addError(userId: string, error: string) { + if (this.state.errors.length < 100) { + this.state.errors.push({ userId, error }); + } + } + + complete() { + this.state.status = 'completed'; + this.state.completedAt = new Date(); + } + + fail() { + this.state.status = 'failed'; + this.state.completedAt = new Date(); + } + + getState(): MigrationState { + return { ...this.state }; + } + + getProgress(): number { + if (this.state.totalUsers === 0) return 0; + return Math.round((this.state.processedUsers / this.state.totalUsers) * 100); + } + + getETA(): string | null { + if (!this.state.startedAt || this.state.processedUsers === 0) { + return null; + } + + const elapsed = Date.now() - this.state.startedAt.getTime(); + const avgTimePerUser = elapsed / this.state.processedUsers; + const remainingUsers = this.state.totalUsers - this.state.processedUsers; + const remainingMs = avgTimePerUser * remainingUsers; + + const seconds = Math.floor(remainingMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + } +} + +// ============================================================================ +// DATABASE CONNECTIONS +// ============================================================================ + +const fromDB = new Pool({ + connectionString: process.env.FROM_DATABASE_URL, +}); + +const toDB = new Pool({ + connectionString: process.env.TO_DATABASE_URL, +}); + +// ============================================================================ +// BETTER AUTH VALIDATION +// ============================================================================ + +/** + * Validates that the imported Better Auth instance meets migration requirements + */ +async function validateAuthConfig() { + const ctx = await auth.$context; + const errors: string[] = []; + + // Check emailAndPassword + if (!ctx.options.emailAndPassword?.enabled) { + errors.push('emailAndPassword.enabled must be true'); + } + + // Check required plugins + const requiredPlugins = ['admin', 'anonymous', 'phone-number']; + const plugins = ctx.options.plugins || []; + const pluginIds = plugins.map((p: any) => p.id); + + for (const required of requiredPlugins) { + if (!pluginIds.includes(required)) { + errors.push(`Missing required plugin: ${required}`); + } + } + + // Check required additional fields + const additionalFields = ctx.options.user?.additionalFields || {}; + const requiredFields: Record = { + userMetadata: { type: 'json', required: false, input: false }, + appMetadata: { type: 'json', required: false, input: false }, + invitedAt: { type: 'date', required: false, input: false }, + lastSignInAt: { type: 'date', required: false, input: false }, + }; + + for (const [fieldName, expectedConfig] of Object.entries(requiredFields)) { + const fieldConfig = additionalFields[fieldName]; + + if (!fieldConfig) { + errors.push(`Missing required user.additionalFields: ${fieldName}`); + } else { + // Validate field configuration + if (fieldConfig.type !== expectedConfig.type) { + errors.push( + `user.additionalFields.${fieldName} must have type: '${expectedConfig.type}' (got '${fieldConfig.type}')`, + ); + } + if (fieldConfig.required !== expectedConfig.required) { + errors.push( + `user.additionalFields.${fieldName} must have required: ${expectedConfig.required}`, + ); + } + if (fieldConfig.input !== expectedConfig.input) { + errors.push(`user.additionalFields.${fieldName} must have input: ${expectedConfig.input}`); + } + } + } + + if (errors.length > 0) { + console.error('\n🟧 Better Auth Configuration Errors:\n'); + errors.forEach((err) => console.error(` ${err}`)); + console.error('\n🟧 Please update your Better Auth configuration to include:\n'); + console.error(' 1. emailAndPassword: { enabled: true }'); + console.error(' 2. plugins: [admin(), anonymous(), phoneNumber()]'); + console.error( + ' 3. user.additionalFields: { userMetadata, appMetadata, invitedAt, lastSignInAt }\n', + ); + process.exit(1); + } + + return ctx; +} + +// ============================================================================ +// MIGRATION LOGIC +// ============================================================================ + +const stateManager = new MigrationStateManager(); + +let ctxCache: { + hasAnonymousPlugin: boolean; + hasAdminPlugin: boolean; + hasPhoneNumberPlugin: boolean; + supportedProviders: string[]; +} | null = null; + +async function processBatch( + users: SupabaseUserFromDB[], + ctx: any, +): Promise<{ + success: number; + failure: number; + skip: number; + errors: Array<{ userId: string; error: string }>; +}> { + const stats = { + success: 0, + failure: 0, + skip: 0, + errors: [] as Array<{ userId: string; error: string }>, + }; + + if (!ctxCache) { + ctxCache = { + hasAdminPlugin: ctx.options.plugins?.some((p: any) => p.id === 'admin') || false, + hasAnonymousPlugin: ctx.options.plugins?.some((p: any) => p.id === 'anonymous') || false, + hasPhoneNumberPlugin: ctx.options.plugins?.some((p: any) => p.id === 'phone-number') || false, + supportedProviders: Object.keys(ctx.options.socialProviders || {}), + }; + } + + const { hasAdminPlugin, hasAnonymousPlugin, hasPhoneNumberPlugin, supportedProviders } = ctxCache; + + const validUsersData: Array<{ user: SupabaseUserFromDB; userData: UserInsertData }> = []; + + for (const user of users) { + if (!user.email && !user.phone) { + stats.skip++; + continue; + } + if (!user.email && !hasPhoneNumberPlugin) { + stats.skip++; + continue; + } + if (user.deleted_at) { + stats.skip++; + continue; + } + if (user.banned_until && !hasAdminPlugin) { + stats.skip++; + continue; + } + + const getTempEmail = (phone: string) => + `${phone.replace(/[^0-9]/g, '')}@${CONFIG.tempEmailDomain}`; + + const getName = (): string => { + if (user.raw_user_meta_data?.name) return user.raw_user_meta_data.name; + if (user.raw_user_meta_data?.full_name) return user.raw_user_meta_data.full_name; + if (user.raw_user_meta_data?.username) return user.raw_user_meta_data.username; + if (user.raw_user_meta_data?.user_name) return user.raw_user_meta_data.user_name; + + const firstId = user.identities?.[0]; + if (firstId?.identity_data?.name) return firstId.identity_data.name; + if (firstId?.identity_data?.full_name) return firstId.identity_data.full_name; + if (firstId?.identity_data?.username) return firstId.identity_data.username; + if (firstId?.identity_data?.preferred_username) + return firstId.identity_data.preferred_username; + + if (user.email) return user.email.split('@')[0]!; + if (user.phone) return user.phone; + + return 'Unknown'; + }; + + const getImage = (): string | undefined => { + if (user.raw_user_meta_data?.avatar_url) return user.raw_user_meta_data.avatar_url; + if (user.raw_user_meta_data?.picture) return user.raw_user_meta_data.picture; + const firstId = user.identities?.[0]; + if (firstId?.identity_data?.avatar_url) return firstId.identity_data.avatar_url; + if (firstId?.identity_data?.picture) return firstId.identity_data.picture; + return undefined; + }; + + const userData: UserInsertData = { + id: user.id, + email: user.email || (user.phone ? getTempEmail(user.phone) : null), + emailVerified: !!user.email_confirmed_at, + name: getName(), + image: getImage(), + createdAt: user.created_at, + updatedAt: user.updated_at, + }; + + if (hasAnonymousPlugin) userData.isAnonymous = user.is_anonymous; + if (hasPhoneNumberPlugin && user.phone) { + userData.phoneNumber = user.phone; + userData.phoneNumberVerified = !!user.phone_confirmed_at; + } + + if (hasAdminPlugin) { + userData.role = user.is_super_admin ? 'admin' : user.role || 'user'; + if (user.banned_until) { + const banExpires = new Date(user.banned_until); + if (banExpires > new Date()) { + userData.banned = true; + userData.banExpires = banExpires; + userData.banReason = 'Migrated from Supabase (banned)'; + } else { + userData.banned = false; + } + } else { + userData.banned = false; + } + } + + if (user.raw_user_meta_data && Object.keys(user.raw_user_meta_data).length > 0) { + userData.userMetadata = user.raw_user_meta_data; + } + if (user.raw_app_meta_data && Object.keys(user.raw_app_meta_data).length > 0) { + userData.appMetadata = user.raw_app_meta_data; + } + if (user.invited_at) userData.invitedAt = user.invited_at; + if (user.last_sign_in_at) userData.lastSignInAt = user.last_sign_in_at; + + validUsersData.push({ user, userData }); + } + + if (validUsersData.length === 0) { + return stats; + } + + try { + await toDB.query('BEGIN'); + + const allFields = new Set(); + validUsersData.forEach(({ userData }) => { + Object.keys(userData).forEach((key) => allFields.add(key)); + }); + const fields = Array.from(allFields); + + const maxParamsPerQuery = 65000; + const fieldsPerUser = fields.length; + const usersPerChunk = Math.floor(maxParamsPerQuery / fieldsPerUser); + + for (let i = 0; i < validUsersData.length; i += usersPerChunk) { + const chunk = validUsersData.slice(i, i + usersPerChunk); + + const placeholders: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + for (const { userData } of chunk) { + const userPlaceholders = fields.map((field) => { + values.push(userData[field] ?? null); + return `$${paramIndex++}`; + }); + placeholders.push(`(${userPlaceholders.join(', ')})`); + } + + await toDB.query( + ` + INSERT INTO "user" (${fields.map((f) => `"${f}"`).join(', ')}) + VALUES ${placeholders.join(', ')} + ON CONFLICT (id) DO NOTHING + `, + values, + ); + } + + const accountsData: AccountInsertData[] = []; + + for (const { user } of validUsersData) { + for (const identity of user.identities ?? []) { + if (identity.provider === 'email') { + accountsData.push({ + id: generateId(), + userId: user.id, + providerId: 'credential', + accountId: user.id, + password: user.encrypted_password || null, + createdAt: user.created_at, + updatedAt: user.updated_at, + }); + } + + if (supportedProviders.includes(identity.provider)) { + accountsData.push({ + id: generateId(), + userId: user.id, + providerId: identity.provider, + accountId: identity.identity_data?.sub || identity.provider_id, + password: null, + createdAt: identity.created_at ?? user.created_at, + updatedAt: identity.updated_at ?? user.updated_at, + }); + } + } + } + + if (accountsData.length > 0) { + const maxParamsPerQuery = 65000; + const fieldsPerAccount = 7; + const accountsPerChunk = Math.floor(maxParamsPerQuery / fieldsPerAccount); + + for (let i = 0; i < accountsData.length; i += accountsPerChunk) { + const chunk = accountsData.slice(i, i + accountsPerChunk); + + const accountPlaceholders: string[] = []; + const accountValues: any[] = []; + let paramIndex = 1; + + for (const acc of chunk) { + accountPlaceholders.push( + `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`, + ); + accountValues.push( + acc.id, + acc.userId, + acc.providerId, + acc.accountId, + acc.password, + acc.createdAt, + acc.updatedAt, + ); + } + + await toDB.query( + ` + INSERT INTO "account" ("id", "userId", "providerId", "accountId", "password", "createdAt", "updatedAt") + VALUES ${accountPlaceholders.join(', ')} + ON CONFLICT ("id") DO NOTHING + `, + accountValues, + ); + } + } + + await toDB.query('COMMIT'); + stats.success = validUsersData.length; + } catch (error: any) { + await toDB.query('ROLLBACK'); + console.error('[TRANSACTION] Batch failed, rolled back:', error.message); + stats.failure = validUsersData.length; + if (stats.errors.length < 100) { + stats.errors.push({ userId: 'bulk', error: error.message }); + } + } + + return stats; +} + +async function migrateFromSupabase() { + const { batchSize, resumeFromId } = CONFIG; + + console.log('[MIGRATION] Starting migration with config:', CONFIG); + + // Validate Better Auth configuration + const ctx = await validateAuthConfig(); + + try { + const countResult = await fromDB.query<{ count: string }>( + ` + SELECT COUNT(*) as count FROM auth.users + ${resumeFromId ? 'WHERE id > $1' : ''} + `, + resumeFromId ? [resumeFromId] : [], + ); + + const totalUsers = parseInt(countResult.rows[0]?.count || '0', 10); + + console.log(`[MIGRATION] Starting migration for ${totalUsers.toLocaleString()} users`); + console.log(`[MIGRATION] Batch size: ${batchSize}\n`); + + stateManager.start(totalUsers, batchSize); + + let lastProcessedId: string | null = resumeFromId; + let hasMore = true; + let batchNumber = 0; + + while (hasMore) { + batchNumber++; + const batchStart = Date.now(); + + const result: { rows: SupabaseUserFromDB[] } = await fromDB.query( + ` + SELECT + u.*, + COALESCE( + json_agg( + i.* ORDER BY i.id + ) FILTER (WHERE i.id IS NOT NULL), + '[]'::json + ) as identities + FROM auth.users u + LEFT JOIN auth.identities i ON u.id = i.user_id + ${lastProcessedId ? 'WHERE u.id > $1' : ''} + GROUP BY u.id + ORDER BY u.id ASC + LIMIT $${lastProcessedId ? '2' : '1'} + `, + lastProcessedId ? [lastProcessedId, batchSize] : [batchSize], + ); + + const batch: SupabaseUserFromDB[] = result.rows; + hasMore = batch.length === batchSize; + + if (batch.length === 0) break; + + console.log( + `\nBatch ${batchNumber}/${Math.ceil(totalUsers / batchSize)} (${batch.length} users)`, + ); + + const stats = await processBatch(batch, ctx); + + lastProcessedId = batch[batch.length - 1]!.id; + stateManager.updateProgress( + batch.length, + stats.success, + stats.failure, + stats.skip, + lastProcessedId, + ); + + stats.errors.forEach((err) => stateManager.addError(err.userId, err.error)); + + const batchTime = ((Date.now() - batchStart) / 1000).toFixed(2); + const usersPerSec = (batch.length / parseFloat(batchTime)).toFixed(0); + + const state = stateManager.getState(); + console.log(`Success: ${stats.success} | Skip: ${stats.skip} | Failure: ${stats.failure}`); + console.log( + `Progress: ${stateManager.getProgress()}% (${state.processedUsers.toLocaleString()}/${state.totalUsers.toLocaleString()})`, + ); + console.log(`Speed: ${usersPerSec} users/sec (${batchTime}s for this batch)`); + + const eta = stateManager.getETA(); + if (eta) { + console.log(`ETA: ${eta}`); + } + } + + stateManager.complete(); + const finalState = stateManager.getState(); + + console.log('\nšŸŽ‰ Migration completed'); + console.log(` - Success: ${finalState.successCount.toLocaleString()}`); + console.log(` - Skipped: ${finalState.skipCount.toLocaleString()}`); + console.log(` - Failed: ${finalState.failureCount.toLocaleString()}`); + + const totalTime = + finalState.completedAt && finalState.startedAt + ? ((finalState.completedAt.getTime() - finalState.startedAt.getTime()) / 1000 / 60).toFixed( + 1, + ) + : '0'; + console.log(` Total time: ${totalTime} minutes`); + + if (finalState.errors.length > 0) { + console.log(`\nFirst ${Math.min(10, finalState.errors.length)} errors:`); + finalState.errors.slice(0, 10).forEach((err) => { + console.log(`- User ${err.userId}: ${err.error}`); + }); + } + + return finalState; + } catch (error) { + stateManager.fail(); + console.error('\nMigration failed:', error); + throw error; + } finally { + await fromDB.end(); + await toDB.end(); + } +} + +// ============================================================================ +// MAIN ENTRY POINT +// ============================================================================ + +async function main() { + console.log('šŸš€ Supabase Auth → Better Auth Migration\n'); + + if (!process.env.FROM_DATABASE_URL) { + console.error('Error: FROM_DATABASE_URL environment variable is required'); + process.exit(1); + } + if (!process.env.TO_DATABASE_URL) { + console.error('Error: TO_DATABASE_URL environment variable is required'); + process.exit(1); + } + + try { + await migrateFromSupabase(); + process.exit(0); + } catch (error) { + console.error('\nMigration failed:', error); + process.exit(1); + } +} +main(); ``` + +You can configure the script using `CONFIG` inside the script. + +- `batchSize`: Number of users to process in each batch. Default: 5000 +- `resumeFromId`: Resume from a specific user ID (cursor-based pagination). Default: null +- `tempEmailDomain`: Temporary email domain for phone-only users. Default: "temp.better-auth.com" - -### Customize the migration script (Optional) - -- `name`: the migration script will use the user's email as the name. You might want to customize it if you have the user display name in your database. -- `socialProviderList`: the migration script will use the social providers you have enabled in your auth config. You might want to customize it if you have additional social providers that you haven't enabled in your auth config. -- `role`: remove `role` if you're not using the `admin` plugin -- `isAnonymous`: remove `isAnonymous` if you're not using the `anonymous` plugin. -- update other tables that reference the `users` table to use the `id` field. - ### Run the migration script