Use Drizzle adapter and Drizzle CLI auth migrations

This commit is contained in:
2026-02-24 16:21:03 -05:00
parent 11eeafafef
commit 0ab464f910
10 changed files with 1133 additions and 110 deletions

View File

@@ -1,18 +1,13 @@
import { betterAuth } from 'better-auth';
import { getMigrations } from 'better-auth/db';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { admin, magicLink, organization } from 'better-auth/plugins';
import { Pool } from 'pg';
declare global {
// eslint-disable-next-line no-var
var __fiscalAuthPgPool: Pool | undefined;
}
import { db } from '@/lib/server/db';
import { authSchema } from '@/lib/server/db/schema';
type BetterAuthInstance = ReturnType<typeof betterAuth>;
let authInstance: BetterAuthInstance | null = null;
let migrationPromise: Promise<void> | null = null;
function parseCsvList(value: string | undefined) {
return (value ?? '')
@@ -21,80 +16,6 @@ function parseCsvList(value: string | undefined) {
.filter((entry) => entry.length > 0);
}
function isPostgresConnectionString(value: string | undefined) {
if (!value) {
return false;
}
try {
const protocol = new URL(value).protocol.toLowerCase();
return protocol === 'postgres:' || protocol === 'postgresql:';
} catch {
return /^postgres(?:ql)?:\/\//i.test(value);
}
}
function splitSqlStatements(sqlText: string) {
return sqlText
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function buildPostgresMigrationPlan(sqlText: string) {
const immediateStatements: string[] = [];
const deferredIndexStatements: string[] = [];
const addIndexPattern = /^alter table\s+([^\s]+)\s+add\s+index\s+([^\s]+)\s+\((.+)\)$/i;
for (const rawStatement of splitSqlStatements(sqlText)) {
const statement = rawStatement.replace(/\s+/g, ' ').trim();
const match = statement.match(addIndexPattern);
if (!match) {
immediateStatements.push(statement);
continue;
}
const [, tableName, indexName, columns] = match;
deferredIndexStatements.push(`create index if not exists ${indexName} on ${tableName} (${columns})`);
}
return [...immediateStatements, ...deferredIndexStatements];
}
async function runPostgresMigrations(pool: Pool, sqlText: string) {
const statements = buildPostgresMigrationPlan(sqlText);
if (statements.length === 0) {
return;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const statement of statements) {
await client.query(statement);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
function getPool() {
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
throw new Error('DATABASE_URL is required for Better Auth PostgreSQL adapter.');
}
if (!globalThis.__fiscalAuthPgPool) {
globalThis.__fiscalAuthPgPool = new Pool({ connectionString });
}
return globalThis.__fiscalAuthPgPool;
}
function buildAuth() {
const adminUserIds = parseCsvList(process.env.BETTER_AUTH_ADMIN_USER_IDS);
const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS);
@@ -104,7 +25,10 @@ function buildAuth() {
const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined;
return betterAuth({
database: getPool(),
database: drizzleAdapter(db, {
provider: 'pg',
schema: authSchema
}),
baseURL,
secret,
emailAndPassword: {
@@ -133,28 +57,5 @@ export function getAuth() {
}
export async function ensureAuthSchema() {
const auth = getAuth();
const connectionString = process.env.DATABASE_URL?.trim();
if (!migrationPromise) {
migrationPromise = (async () => {
const migrations = await getMigrations(auth.options);
if (isPostgresConnectionString(connectionString)) {
const sql = await migrations.compileMigrations();
await runPostgresMigrations(getPool(), sql);
return;
}
await migrations.runMigrations();
})();
}
try {
await migrationPromise;
} catch (error) {
migrationPromise = null;
throw error;
}
return auth;
return getAuth();
}

41
lib/server/db/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { authSchema } from './schema';
type AuthDrizzleDb = ReturnType<typeof createDb>;
declare global {
// eslint-disable-next-line no-var
var __fiscalAuthPgPool: Pool | undefined;
// eslint-disable-next-line no-var
var __fiscalAuthDrizzleDb: AuthDrizzleDb | undefined;
}
function getConnectionString() {
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
throw new Error('DATABASE_URL is required for PostgreSQL.');
}
return connectionString;
}
export function getPool() {
if (!globalThis.__fiscalAuthPgPool) {
globalThis.__fiscalAuthPgPool = new Pool({
connectionString: getConnectionString()
});
}
return globalThis.__fiscalAuthPgPool;
}
function createDb() {
return drizzle(getPool(), { schema: authSchema });
}
export const db = globalThis.__fiscalAuthDrizzleDb ?? createDb();
if (!globalThis.__fiscalAuthDrizzleDb) {
globalThis.__fiscalAuthDrizzleDb = db;
}

113
lib/server/db/schema.ts Normal file
View File

@@ -0,0 +1,113 @@
import { boolean, index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
const dateColumn = {
withTimezone: true,
mode: 'date'
} as const;
export const user = pgTable('user', {
id: text('id').primaryKey().notNull(),
name: text('name').notNull(),
email: text('email').notNull(),
emailVerified: boolean('emailVerified').notNull().default(false),
image: text('image'),
createdAt: timestamp('createdAt', dateColumn).notNull(),
updatedAt: timestamp('updatedAt', dateColumn).notNull(),
role: text('role'),
banned: boolean('banned').default(false),
banReason: text('banReason'),
banExpires: timestamp('banExpires', dateColumn)
}, (table) => ({
userEmailUnique: uniqueIndex('user_email_uidx').on(table.email)
}));
export const organization = pgTable('organization', {
id: text('id').primaryKey().notNull(),
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
createdAt: timestamp('createdAt', dateColumn).notNull(),
metadata: text('metadata')
}, (table) => ({
organizationSlugUnique: uniqueIndex('organization_slug_uidx').on(table.slug)
}));
export const session = pgTable('session', {
id: text('id').primaryKey().notNull(),
expiresAt: timestamp('expiresAt', dateColumn).notNull(),
token: text('token').notNull(),
createdAt: timestamp('createdAt', dateColumn).notNull(),
updatedAt: timestamp('updatedAt', dateColumn).notNull(),
ipAddress: text('ipAddress'),
userAgent: text('userAgent'),
userId: text('userId').notNull().references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonatedBy'),
activeOrganizationId: text('activeOrganizationId')
}, (table) => ({
sessionTokenUnique: uniqueIndex('session_token_uidx').on(table.token),
sessionUserIdIndex: index('session_userId_idx').on(table.userId)
}));
export const account = pgTable('account', {
id: text('id').primaryKey().notNull(),
accountId: text('accountId').notNull(),
providerId: text('providerId').notNull(),
userId: text('userId').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('accessToken'),
refreshToken: text('refreshToken'),
idToken: text('idToken'),
accessTokenExpiresAt: timestamp('accessTokenExpiresAt', dateColumn),
refreshTokenExpiresAt: timestamp('refreshTokenExpiresAt', dateColumn),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('createdAt', dateColumn).notNull(),
updatedAt: timestamp('updatedAt', dateColumn).notNull()
}, (table) => ({
accountUserIdIndex: index('account_userId_idx').on(table.userId)
}));
export const verification = pgTable('verification', {
id: text('id').primaryKey().notNull(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expiresAt', dateColumn).notNull(),
createdAt: timestamp('createdAt', dateColumn).notNull(),
updatedAt: timestamp('updatedAt', dateColumn).notNull()
}, (table) => ({
verificationIdentifierIndex: index('verification_identifier_idx').on(table.identifier)
}));
export const member = pgTable('member', {
id: text('id').primaryKey().notNull(),
organizationId: text('organizationId').notNull().references(() => organization.id, { onDelete: 'cascade' }),
userId: text('userId').notNull().references(() => user.id, { onDelete: 'cascade' }),
role: text('role').notNull().default('member'),
createdAt: timestamp('createdAt', dateColumn).notNull()
}, (table) => ({
memberOrganizationIdIndex: index('member_organizationId_idx').on(table.organizationId),
memberUserIdIndex: index('member_userId_idx').on(table.userId)
}));
export const invitation = pgTable('invitation', {
id: text('id').primaryKey().notNull(),
organizationId: text('organizationId').notNull().references(() => organization.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: text('role'),
status: text('status').notNull().default('pending'),
expiresAt: timestamp('expiresAt', dateColumn).notNull(),
createdAt: timestamp('createdAt', dateColumn).notNull(),
inviterId: text('inviterId').notNull().references(() => user.id, { onDelete: 'cascade' })
}, (table) => ({
invitationOrganizationIdIndex: index('invitation_organizationId_idx').on(table.organizationId),
invitationEmailIndex: index('invitation_email_idx').on(table.email)
}));
export const authSchema = {
user,
session,
account,
verification,
organization,
member,
invitation
};