From a4fbe3c8dfd787ef519820f9e2d7bece063262ee Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:18:40 +0100 Subject: [PATCH] remove proxy pattern in db and auth (#513) * refactor: add nitro bootstrap plugin to ensure app is started before first call * refactor(bootstrap): avoid duplicate event firing * refactor: extract common setup in initModule function * refactor: remove proxy pattern for db and auth Since we migrated away from rr this is not needed anymore as the bundler correctly split chunks --- app/server.ts | 16 +- app/server/db/db.ts | 63 +---- app/server/lib/auth.ts | 301 +++++++++++----------- app/server/modules/lifecycle/bootstrap.ts | 4 +- app/server/modules/lifecycle/startup.ts | 7 - app/test/setup.ts | 7 +- 6 files changed, 169 insertions(+), 229 deletions(-) diff --git a/app/server.ts b/app/server.ts index e0f3136..0ccf59f 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,24 +1,18 @@ import { logger } from "./server/utils/logger"; import { shutdown } from "./server/modules/lifecycle/shutdown"; import { runCLI } from "./server/cli"; -import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server"; +import { + createStartHandler, + defaultStreamHandler, + defineHandlerCallback, +} from "@tanstack/react-start/server"; import { createServerEntry } from "@tanstack/react-start/server-entry"; -import { initAuth } from "~/server/lib/auth"; -import { setSchema } from "./server/db/db"; -import * as schema from "./server/db/schema"; -import { toMessage } from "./server/utils/errors"; const cliRun = await runCLI(Bun.argv); if (cliRun) { process.exit(0); } -setSchema(schema); -await initAuth().catch((err) => { - logger.error(`Error initializing auth: ${toMessage(err)}`); - throw err; -}); - const customHandler = defineHandlerCallback((ctx) => { return defaultStreamHandler(ctx); }); diff --git a/app/server/db/db.ts b/app/server/db/db.ts index 0f6862a..213b6ba 100644 --- a/app/server/db/db.ts +++ b/app/server/db/db.ts @@ -6,53 +6,22 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { DATABASE_URL } from "../core/constants"; import fs from "node:fs"; import { config } from "../core/config"; -import type * as schemaTypes from "./schema"; +import * as schema from "./schema"; -/** - * TODO: try to remove this if moving away from react-router. - * The rr vite plugin doesn't let us customize the chunk names - * to isolate the db initialization code from the rest of the server code. - */ -let _sqlite: Database | undefined; -let _db: ReturnType | undefined; -let _schema: typeof schemaTypes | undefined; +fs.mkdirSync(path.dirname(DATABASE_URL), { recursive: true }); -/** - * Sets the database schema. This must be called before any database operations. - */ -export const setSchema = (schema: typeof schemaTypes) => { - _schema = schema; -}; +if ( + fs.existsSync(path.join(path.dirname(DATABASE_URL), "ironmount.db")) && + !fs.existsSync(DATABASE_URL) +) { + fs.renameSync( + path.join(path.dirname(DATABASE_URL), "ironmount.db"), + DATABASE_URL, + ); +} -const initDb = () => { - if (!_schema) { - throw new Error("Database schema not set. Call setSchema() before accessing the database."); - } - - fs.mkdirSync(path.dirname(DATABASE_URL), { recursive: true }); - - if (fs.existsSync(path.join(path.dirname(DATABASE_URL), "ironmount.db")) && !fs.existsSync(DATABASE_URL)) { - fs.renameSync(path.join(path.dirname(DATABASE_URL), "ironmount.db"), DATABASE_URL); - } - - _sqlite = new Database(DATABASE_URL); - return drizzle({ client: _sqlite, relations, schema: _schema }); -}; - -/** - * Database instance (Proxy for lazy initialization) - */ -export const db = new Proxy( - {}, - { - get(_, prop, receiver) { - if (!_db) { - _db = initDb(); - } - return Reflect.get(_db, prop, receiver); - }, - }, -) as ReturnType; +const sqlite = new Database(DATABASE_URL); +export const db = drizzle({ client: sqlite, relations, schema }); export const runDbMigrations = () => { let migrationsFolder: string; @@ -67,9 +36,5 @@ export const runDbMigrations = () => { migrate(db, { migrationsFolder }); - if (!_sqlite) { - throw new Error("Database not initialized"); - } - - _sqlite.run("PRAGMA foreign_keys = ON;"); + sqlite.run("PRAGMA foreign_keys = ON;"); }; diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 3cbb285..b1680b5 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -6,177 +6,172 @@ import { type MiddlewareOptions, } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins"; +import { + admin, + createAuthMiddleware, + twoFactor, + username, + organization, +} from "better-auth/plugins"; import { UnauthorizedError } from "http-errors-enhanced"; import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user"; import { eq } from "drizzle-orm"; import { config } from "../core/config"; import { db } from "../db/db"; import { cryptoUtils } from "../utils/crypto"; -import { organization as organizationTable, member, usersTable } from "../db/schema"; +import { + organization as organizationTable, + member, + usersTable, +} from "../db/schema"; import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; import { authService } from "../modules/auth/auth.service"; import { tanstackStartCookies } from "better-auth/tanstack-start"; -export type AuthMiddlewareContext = MiddlewareContext>; +export type AuthMiddlewareContext = MiddlewareContext< + MiddlewareOptions, + AuthContext +>; -const createBetterAuth = (secret: string) => { - return betterAuth({ - secret, - baseURL: config.baseUrl, - trustedOrigins: config.trustedOrigins, - advanced: { - cookiePrefix: "zerobyte", - useSecureCookies: config.isSecure, - }, - onAPIError: { - throw: true, - }, - hooks: { - before: createAuthMiddleware(async (ctx) => { - await ensureOnlyOneUser(ctx); - await convertLegacyUserOnFirstLogin(ctx); - }), - }, - database: drizzleAdapter(db, { - provider: "sqlite", +export const auth = betterAuth({ + secret: await cryptoUtils.deriveSecret("better-auth"), + baseURL: config.baseUrl, + trustedOrigins: config.trustedOrigins, + advanced: { + cookiePrefix: "zerobyte", + useSecureCookies: config.isSecure, + }, + onAPIError: { + throw: true, + }, + hooks: { + before: createAuthMiddleware(async (ctx) => { + await ensureOnlyOneUser(ctx); + await convertLegacyUserOnFirstLogin(ctx); }), - databaseHooks: { - user: { - delete: { - before: async (user) => { - await authService.cleanupUserOrganizations(user.id); - }, - }, - create: { - before: async (user) => { - const anyUser = await db.query.usersTable.findFirst(); - const isFirstUser = !anyUser; - - if (isFirstUser) { - user.role = "admin"; - } - - return { data: user }; - }, - after: async (user) => { - const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4); - - const resticPassword = cryptoUtils.generateResticPassword(); - const metadata = { - resticPassword: await cryptoUtils.sealSecret(resticPassword), - }; - - try { - await db.transaction(async (tx) => { - const orgId = Bun.randomUUIDv7(); - - await tx.insert(organizationTable).values({ - name: `${user.name}'s Workspace`, - slug: slug, - id: orgId, - createdAt: new Date(), - metadata, - }); - - await tx.insert(member).values({ - id: Bun.randomUUIDv7(), - userId: user.id, - role: "owner", - organizationId: orgId, - createdAt: new Date(), - }); - }); - } catch { - await db.delete(usersTable).where(eq(usersTable.id, user.id)); - - throw new Error(`Failed to create organization for user ${user.id}`); - } - }, - }, - }, - session: { - create: { - before: async (session) => { - const orgMembership = await db.query.member.findFirst({ - where: { userId: session.userId }, - }); - - if (!orgMembership) { - throw new UnauthorizedError("User does not belong to any organization"); - } - - return { - data: { - ...session, - activeOrganizationId: orgMembership?.organizationId, - }, - }; - }, - }, - }, - }, - emailAndPassword: { - enabled: true, - }, + }, + database: drizzleAdapter(db, { + provider: "sqlite", + }), + databaseHooks: { user: { - modelName: "usersTable", - additionalFields: { - username: { - type: "string", - returned: true, - required: true, + delete: { + before: async (user) => { + await authService.cleanupUserOrganizations(user.id); }, - hasDownloadedResticPassword: { - type: "boolean", - returned: true, + }, + create: { + before: async (user) => { + const anyUser = await db.query.usersTable.findFirst(); + const isFirstUser = !anyUser; + + if (isFirstUser) { + user.role = "admin"; + } + + return { data: user }; + }, + after: async (user) => { + const slug = + user.email.split("@")[0] + + "-" + + Math.random().toString(36).slice(-4); + + const resticPassword = cryptoUtils.generateResticPassword(); + const metadata = { + resticPassword: + await cryptoUtils.sealSecret(resticPassword), + }; + + try { + await db.transaction(async (tx) => { + const orgId = Bun.randomUUIDv7(); + + await tx.insert(organizationTable).values({ + name: `${user.name}'s Workspace`, + slug: slug, + id: orgId, + createdAt: new Date(), + metadata, + }); + + await tx.insert(member).values({ + id: Bun.randomUUIDv7(), + userId: user.id, + role: "owner", + organizationId: orgId, + createdAt: new Date(), + }); + }); + } catch { + await db + .delete(usersTable) + .where(eq(usersTable.id, user.id)); + + throw new Error( + `Failed to create organization for user ${user.id}`, + ); + } }, }, }, session: { - modelName: "sessionsTable", - }, - plugins: [ - username(), - admin({ - defaultRole: "user", - }), - organization({ - allowUserToCreateOrganization: false, - }), - twoFactor({ - backupCodeOptions: { - storeBackupCodes: "encrypted", - amount: 5, + create: { + before: async (session) => { + const orgMembership = await db.query.member.findFirst({ + where: { userId: session.userId }, + }); + + if (!orgMembership) { + throw new UnauthorizedError( + "User does not belong to any organization", + ); + } + + return { + data: { + ...session, + activeOrganizationId: orgMembership?.organizationId, + }, + }; }, - }), - tanstackStartCookies(), - ], - }); -}; - -type Auth = ReturnType; - -let _auth: Auth | null = null; - -const createAuth = async (): Promise => { - if (_auth) return _auth; - - _auth = createBetterAuth(await cryptoUtils.deriveSecret("better-auth")); - - return _auth; -}; - -export const auth = new Proxy( - {}, - { - get(_, prop, receiver) { - if (!_auth) { - throw new Error("Auth not initialized. Call initAuth() first."); - } - return Reflect.get(_auth, prop, receiver); + }, }, }, -) as Auth; - -export const initAuth = createAuth; + emailAndPassword: { + enabled: true, + }, + user: { + modelName: "usersTable", + additionalFields: { + username: { + type: "string", + returned: true, + required: true, + }, + hasDownloadedResticPassword: { + type: "boolean", + returned: true, + }, + }, + }, + session: { + modelName: "sessionsTable", + }, + plugins: [ + username(), + admin({ + defaultRole: "user", + }), + organization({ + allowUserToCreateOrganization: false, + }), + twoFactor({ + backupCodeOptions: { + storeBackupCodes: "encrypted", + amount: 5, + }, + }), + tanstackStartCookies(), + ], +}); diff --git a/app/server/modules/lifecycle/bootstrap.ts b/app/server/modules/lifecycle/bootstrap.ts index 529b4cc..92bb642 100644 --- a/app/server/modules/lifecycle/bootstrap.ts +++ b/app/server/modules/lifecycle/bootstrap.ts @@ -1,12 +1,10 @@ -import * as schema from "../../db/schema"; -import { runDbMigrations, setSchema } from "../../db/db"; +import { runDbMigrations } from "../../db/db"; import { runMigrations } from "./migrations"; import { startup } from "./startup"; let bootstrapPromise: Promise | undefined; const runBootstrap = async () => { - setSchema(schema); runDbMigrations(); await runMigrations(); await startup(); diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index b830733..dbe98f4 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -12,8 +12,6 @@ import { repositoriesService } from "../repositories/repositories.service"; import { notificationsService } from "../notifications/notifications.service"; import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount"; import { cache } from "~/server/utils/cache"; -import { initAuth } from "~/server/lib/auth"; -import { toMessage } from "~/server/utils/errors"; import { withContext } from "~/server/core/request-context"; const ensureLatestConfigurationSchema = async () => { @@ -54,11 +52,6 @@ export const startup = async () => { await Scheduler.start(); await Scheduler.clear(); - await initAuth().catch((err) => { - logger.error(`Error initializing auth: ${toMessage(err)}`); - throw err; - }); - await ensureLatestConfigurationSchema(); const volumes = await db.query.volumesTable.findMany({ diff --git a/app/test/setup.ts b/app/test/setup.ts index 5ca37ec..9760a5d 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -2,11 +2,7 @@ import { beforeAll, mock } from "bun:test"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import path from "node:path"; import { cwd } from "node:process"; -import * as schema from "~/server/db/schema"; -import { db, setSchema } from "~/server/db/db"; -import { initAuth } from "~/server/lib/auth"; - -setSchema(schema); +import { db } from "~/server/db/db"; void mock.module("~/server/utils/logger", () => ({ logger: { @@ -29,5 +25,4 @@ void mock.module("~/server/utils/crypto", () => ({ beforeAll(async () => { const migrationsFolder = path.join(cwd(), "app", "drizzle"); migrate(db, { migrationsFolder }); - await initAuth(); });