Use Drizzle adapter and Drizzle CLI auth migrations
This commit is contained in:
115
lib/auth.ts
115
lib/auth.ts
@@ -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
41
lib/server/db/index.ts
Normal 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
113
lib/server/db/schema.ts
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user