Explorar o código

refactor: migrate app.service to use drizzle

Nicolas Meienberger %!s(int64=2) %!d(string=hai) anos
pai
achega
4f8519b271

+ 15 - 2
src/server/db/schema.ts

@@ -1,8 +1,20 @@
 import { InferModel } from 'drizzle-orm';
 import { InferModel } from 'drizzle-orm';
 import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb } from 'drizzle-orm/pg-core';
 import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb } from 'drizzle-orm/pg-core';
 
 
+const APP_STATUS = {
+  updating: 'updating',
+  missing: 'missing',
+  starting: 'starting',
+  stopping: 'stopping',
+  uninstalling: 'uninstalling',
+  installing: 'installing',
+  stopped: 'stopped',
+  running: 'running',
+} as const;
+export type AppStatus = (typeof APP_STATUS)[keyof typeof APP_STATUS];
+
 export const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']);
 export const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']);
-export const appStatusEnum = pgEnum('app_status_enum', ['updating', 'missing', 'starting', 'stopping', 'uninstalling', 'installing', 'stopped', 'running']);
+export const appStatusEnum = pgEnum('app_status_enum', Object.values(APP_STATUS) as [string, ...string[]]);
 
 
 export const migrations = pgTable('migrations', {
 export const migrations = pgTable('migrations', {
   id: integer('id').notNull(),
   id: integer('id').notNull(),
@@ -36,7 +48,7 @@ export const appTable = pgTable('app', {
   id: varchar('id').notNull(),
   id: varchar('id').notNull(),
   status: appStatusEnum('status').default('stopped').notNull(),
   status: appStatusEnum('status').default('stopped').notNull(),
   lastOpened: timestamp('lastOpened', { withTimezone: true, mode: 'string' }).defaultNow(),
   lastOpened: timestamp('lastOpened', { withTimezone: true, mode: 'string' }).defaultNow(),
-  numOpened: integer('numOpened').notNull(),
+  numOpened: integer('numOpened').default(0).notNull(),
   config: jsonb('config').notNull(),
   config: jsonb('config').notNull(),
   createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(),
   createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(),
   updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(),
   updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(),
@@ -45,3 +57,4 @@ export const appTable = pgTable('app', {
   domain: varchar('domain'),
   domain: varchar('domain'),
 });
 });
 export type App = InferModel<typeof appTable>;
 export type App = InferModel<typeof appTable>;
+export type NewApp = InferModel<typeof appTable, 'insert'>;

+ 2 - 2
src/server/index.ts

@@ -8,7 +8,7 @@ import { getConfig, setConfig } from './core/TipiConfig';
 import { Logger } from './core/Logger';
 import { Logger } from './core/Logger';
 import { runPostgresMigrations } from './run-migration';
 import { runPostgresMigrations } from './run-migration';
 import { AppServiceClass } from './services/apps/apps.service';
 import { AppServiceClass } from './services/apps/apps.service';
-import { prisma } from './db/client';
+import { db } from './db';
 
 
 let conf = {};
 let conf = {};
 let nextApp: NextServer;
 let nextApp: NextServer;
@@ -42,7 +42,7 @@ nextApp.prepare().then(async () => {
   });
   });
 
 
   app.listen(port, async () => {
   app.listen(port, async () => {
-    const appService = new AppServiceClass(prisma);
+    const appService = new AppServiceClass(db);
     EventDispatcher.clear();
     EventDispatcher.clear();
 
 
     // Run database migrations
     // Run database migrations

+ 2 - 2
src/server/routers/app/app.router.ts

@@ -1,11 +1,11 @@
 import { z } from 'zod';
 import { z } from 'zod';
 import { inferRouterOutputs } from '@trpc/server';
 import { inferRouterOutputs } from '@trpc/server';
+import { db } from '@/server/db';
 import { AppServiceClass } from '../../services/apps/apps.service';
 import { AppServiceClass } from '../../services/apps/apps.service';
 import { router, protectedProcedure } from '../../trpc';
 import { router, protectedProcedure } from '../../trpc';
-import { prisma } from '../../db/client';
 
 
 export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
 export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
-const AppService = new AppServiceClass(prisma);
+const AppService = new AppServiceClass(db);
 
 
 const formSchema = z.object({}).catchall(z.any());
 const formSchema = z.object({}).catchall(z.any());
 
 

+ 0 - 20
src/server/routers/auth/auth.router.test.ts

@@ -1,24 +1,4 @@
-import { PrismaClient } from '@prisma/client';
 import { authRouter } from './auth.router';
 import { authRouter } from './auth.router';
-import { getTestDbClient } from '../../../../tests/server/db-connection';
-
-let db: PrismaClient;
-const TEST_SUITE = 'authrouter';
-
-beforeAll(async () => {
-  db = await getTestDbClient(TEST_SUITE);
-  jest.spyOn(console, 'log').mockImplementation(() => {});
-});
-
-beforeEach(async () => {
-  await db.user.deleteMany();
-  // Mute console.log
-});
-
-afterAll(async () => {
-  await db.user.deleteMany();
-  await db.$disconnect();
-});
 
 
 describe('Test: verifyTotp', () => {
 describe('Test: verifyTotp', () => {
   it('should be accessible without an account', async () => {
   it('should be accessible without an account', async () => {

+ 6 - 10
src/server/services/apps/apps.helpers.test.ts

@@ -1,31 +1,27 @@
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { fromAny } from '@total-typescript/shoehorn';
 import { fromAny } from '@total-typescript/shoehorn';
-import { App, PrismaClient } from '@prisma/client';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
+import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
+import { App } from '@/server/db/schema';
 import { setConfig } from '../../core/TipiConfig';
 import { setConfig } from '../../core/TipiConfig';
 import { AppInfo, appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
 import { AppInfo, appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
 import { createApp, createAppConfig } from '../../tests/apps.factory';
 import { createApp, createAppConfig } from '../../tests/apps.factory';
 import { Logger } from '../../core/Logger';
 import { Logger } from '../../core/Logger';
-import { getTestDbClient } from '../../../../tests/server/db-connection';
 
 
-let db: PrismaClient;
+let db: TestDatabase;
 const TEST_SUITE = 'appshelpers';
 const TEST_SUITE = 'appshelpers';
 
 
 beforeAll(async () => {
 beforeAll(async () => {
-  db = await getTestDbClient(TEST_SUITE);
+  db = await createDatabase(TEST_SUITE);
 });
 });
 
 
 beforeEach(async () => {
 beforeEach(async () => {
   jest.mock('fs-extra');
   jest.mock('fs-extra');
-});
-
-afterEach(async () => {
-  await db.app.deleteMany();
+  await clearDatabase(db);
 });
 });
 
 
 afterAll(async () => {
 afterAll(async () => {
-  await db.app.deleteMany();
-  await db.$disconnect();
+  await closeDatabase(db);
 });
 });
 
 
 describe('checkAppRequirements', () => {
 describe('checkAppRequirements', () => {

+ 1 - 1
src/server/services/apps/apps.helpers.ts

@@ -1,7 +1,7 @@
 import crypto from 'crypto';
 import crypto from 'crypto';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { z } from 'zod';
 import { z } from 'zod';
-import { App } from '@prisma/client';
+import { App } from '@/server/db/schema';
 import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
 import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
 import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
 import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
 import { getConfig } from '../../core/TipiConfig';
 import { getConfig } from '../../core/TipiConfig';

+ 23 - 25
src/server/services/apps/apps.service.test.ts

@@ -1,32 +1,30 @@
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import waitForExpect from 'wait-for-expect';
 import waitForExpect from 'wait-for-expect';
-import { PrismaClient } from '@prisma/client';
+import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
 import { AppServiceClass } from './apps.service';
 import { AppServiceClass } from './apps.service';
 import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
 import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
 import { AppInfo, getEnvMap } from './apps.helpers';
 import { AppInfo, getEnvMap } from './apps.helpers';
-import { createApp } from '../../tests/apps.factory';
+import { createApp, getAllApps, getAppById, updateApp } from '../../tests/apps.factory';
 import { APP_STATUS } from './apps.types';
 import { APP_STATUS } from './apps.types';
 import { setConfig } from '../../core/TipiConfig';
 import { setConfig } from '../../core/TipiConfig';
-import { getTestDbClient } from '../../../../tests/server/db-connection';
 
 
-let db: PrismaClient;
+let db: TestDatabase;
 let AppsService: AppServiceClass;
 let AppsService: AppServiceClass;
 const TEST_SUITE = 'appsservice';
 const TEST_SUITE = 'appsservice';
 
 
 beforeAll(async () => {
 beforeAll(async () => {
-  db = await getTestDbClient(TEST_SUITE);
-  AppsService = new AppServiceClass(db);
+  db = await createDatabase(TEST_SUITE);
+  AppsService = new AppServiceClass(db.db);
 });
 });
 
 
 beforeEach(async () => {
 beforeEach(async () => {
   jest.mock('fs-extra');
   jest.mock('fs-extra');
-  await db.app.deleteMany();
+  await clearDatabase(db);
   EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
   EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
 });
 });
 
 
 afterAll(async () => {
 afterAll(async () => {
-  await db.app.deleteMany();
-  await db.$disconnect();
+  await closeDatabase(db);
 });
 });
 
 
 describe('Install app', () => {
 describe('Install app', () => {
@@ -49,7 +47,7 @@ describe('Install app', () => {
   it('Should add app in database', async () => {
   it('Should add app in database', async () => {
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
 
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
 
 
     expect(app).toBeDefined();
     expect(app).toBeDefined();
     expect(app?.id).toBe(app1.id);
     expect(app?.id).toBe(app1.id);
@@ -76,7 +74,7 @@ describe('Install app', () => {
 
 
     await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
     await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
 
 
-    const app = await db?.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
 
 
     expect(app).toBeNull();
     expect(app).toBeNull();
   });
   });
@@ -167,7 +165,7 @@ describe('Install app', () => {
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
     await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
-    const app = await db.app.findUnique({ where: { id: appInfo.id } });
+    const app = await getAppById(appInfo.id, db);
 
 
     expect(app).toBeDefined();
     expect(app).toBeDefined();
   });
   });
@@ -179,7 +177,7 @@ describe('Install app', () => {
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
     await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
-    const app = await db.app.findUnique({ where: { id: appInfo.id } });
+    const app = await getAppById(appInfo.id, db);
 
 
     expect(app).toBeDefined();
     expect(app).toBeDefined();
   });
   });
@@ -230,7 +228,7 @@ describe('Uninstall app', () => {
 
 
   it('App should be installed by default', async () => {
   it('App should be installed by default', async () => {
     // Act
     // Act
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
 
 
     // Assert
     // Assert
     expect(app).toBeDefined();
     expect(app).toBeDefined();
@@ -241,7 +239,7 @@ describe('Uninstall app', () => {
   it('Should correctly remove app from database', async () => {
   it('Should correctly remove app from database', async () => {
     // Act
     // Act
     await AppsService.uninstallApp(app1.id);
     await AppsService.uninstallApp(app1.id);
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
 
 
     // Assert
     // Assert
     expect(app).toBeNull();
     expect(app).toBeNull();
@@ -270,11 +268,11 @@ describe('Uninstall app', () => {
   it('Should throw if uninstall script fails', async () => {
   it('Should throw if uninstall script fails', async () => {
     // Arrange
     // Arrange
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
-    await db.app.update({ where: { id: app1.id }, data: { status: 'updating' } });
+    await updateApp(app1.id, { status: 'updating' }, db);
 
 
     // Act & Assert
     // Act & Assert
     await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
     await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
   });
   });
 });
 });
@@ -330,7 +328,7 @@ describe('Start app', () => {
 
 
     // Act & Assert
     // Act & Assert
     await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
     await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
   });
   });
 });
 });
@@ -363,7 +361,7 @@ describe('Stop app', () => {
 
 
     // Act & Assert
     // Act & Assert
     await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
     await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
     expect(app?.status).toBe(APP_STATUS.RUNNING);
     expect(app?.status).toBe(APP_STATUS.RUNNING);
   });
   });
 });
 });
@@ -585,14 +583,14 @@ describe('Update app', () => {
     // @ts-expect-error - Mocking fs
     // @ts-expect-error - Mocking fs
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
 
 
-    await db.app.update({ where: { id: app1.id }, data: { version: 0 } });
+    await updateApp(app1.id, { version: 0 }, db);
 
 
     const app = await AppsService.updateApp(app1.id);
     const app = await AppsService.updateApp(app1.id);
 
 
     expect(app).toBeDefined();
     expect(app).toBeDefined();
-    expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
-    expect(app.version).toBe(app1.tipi_version);
-    expect(app.status).toBe(APP_STATUS.STOPPED);
+    expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
+    expect(app?.version).toBe(app1.tipi_version);
+    expect(app?.status).toBe(APP_STATUS.STOPPED);
   });
   });
 
 
   it("Should throw if app doesn't exist", async () => {
   it("Should throw if app doesn't exist", async () => {
@@ -608,7 +606,7 @@ describe('Update app', () => {
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
     await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
     await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
-    const app = await db.app.findUnique({ where: { id: app1.id } });
+    const app = await getAppById(app1.id, db);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
     expect(app?.status).toBe(APP_STATUS.STOPPED);
   });
   });
 });
 });
@@ -677,7 +675,7 @@ describe('startAllApps', () => {
 
 
     // Assert
     // Assert
     await waitForExpect(async () => {
     await waitForExpect(async () => {
-      const apps = await db.app.findMany();
+      const apps = await getAllApps(db);
       expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
       expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
     });
     });
   });
   });

+ 81 - 52
src/server/services/apps/apps.service.ts

@@ -1,5 +1,7 @@
 import validator from 'validator';
 import validator from 'validator';
-import { App, PrismaClient } from '@prisma/client';
+import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+import { appTable, App } from '@/server/db/schema';
+import { and, asc, eq, ne, notInArray } from 'drizzle-orm';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo } from './apps.helpers';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo } from './apps.helpers';
 import { getConfig } from '../../core/TipiConfig';
 import { getConfig } from '../../core/TipiConfig';
 import { EventDispatcher } from '../../core/EventDispatcher';
 import { EventDispatcher } from '../../core/EventDispatcher';
@@ -20,10 +22,10 @@ const filterApp = (app: AppInfo): boolean => {
 const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(filterApp);
 const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(filterApp);
 
 
 export class AppServiceClass {
 export class AppServiceClass {
-  private prisma;
+  private db;
 
 
-  constructor(p: PrismaClient) {
-    this.prisma = p;
+  constructor(p: NodePgDatabase) {
+    this.db = p;
   }
   }
 
 
   /**
   /**
@@ -35,10 +37,13 @@ export class AppServiceClass {
    *  @returns {Promise<void>} - A promise that resolves when all apps are started.
    *  @returns {Promise<void>} - A promise that resolves when all apps are started.
    */
    */
   public async startAllApps() {
   public async startAllApps() {
-    const apps = await this.prisma.app.findMany({ where: { status: 'running' }, orderBy: { id: 'asc' } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.status, 'running')).orderBy(asc(appTable.id));
 
 
     // Update all apps with status different than running or stopped to stopped
     // Update all apps with status different than running or stopped to stopped
-    await this.prisma.app.updateMany({ where: { status: { notIn: ['running', 'stopped', 'missing'] } }, data: { status: 'stopped' } });
+    await this.db
+      .update(appTable)
+      .set({ status: 'stopped' })
+      .where(notInArray(appTable.status, ['running', 'stopped']));
 
 
     await Promise.all(
     await Promise.all(
       apps.map(async (app) => {
       apps.map(async (app) => {
@@ -48,17 +53,17 @@ export class AppServiceClass {
           generateEnvFile(app);
           generateEnvFile(app);
           checkEnvFile(app.id);
           checkEnvFile(app.id);
 
 
-          await this.prisma.app.update({ where: { id: app.id }, data: { status: 'starting' } });
+          await this.db.update(appTable).set({ status: 'starting' }).where(eq(appTable.id, app.id));
 
 
           EventDispatcher.dispatchEventAsync('app', ['start', app.id]).then(({ success }) => {
           EventDispatcher.dispatchEventAsync('app', ['start', app.id]).then(({ success }) => {
             if (success) {
             if (success) {
-              this.prisma.app.update({ where: { id: app.id }, data: { status: 'running' } }).then(() => {});
+              this.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, app.id)).execute();
             } else {
             } else {
-              this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } }).then(() => {});
+              this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, app.id)).execute();
             }
             }
           });
           });
         } catch (e) {
         } catch (e) {
-          await this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } });
+          await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, app.id));
           Logger.error(e);
           Logger.error(e);
         }
         }
       }),
       }),
@@ -74,7 +79,8 @@ export class AppServiceClass {
    * @throws {Error} - If the app is not found or the start process fails.
    * @throws {Error} - If the app is not found or the start process fails.
    */
    */
   public startApp = async (appName: string) => {
   public startApp = async (appName: string) => {
-    let app = await this.prisma.app.findUnique({ where: { id: appName } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, appName));
+    const app = apps[0];
 
 
     if (!app) {
     if (!app) {
       throw new Error(`App ${appName} not found`);
       throw new Error(`App ${appName} not found`);
@@ -85,19 +91,18 @@ export class AppServiceClass {
     generateEnvFile(app);
     generateEnvFile(app);
     checkEnvFile(appName);
     checkEnvFile(appName);
 
 
-    await this.prisma.app.update({ where: { id: appName }, data: { status: 'starting' } });
+    await this.db.update(appTable).set({ status: 'starting' }).where(eq(appTable.id, appName));
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
 
 
     if (success) {
     if (success) {
-      await this.prisma.app.update({ where: { id: appName }, data: { status: 'running' } });
+      await this.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, appName));
     } else {
     } else {
-      await this.prisma.app.update({ where: { id: appName }, data: { status: 'stopped' } });
+      await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, appName));
       throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
       throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
     }
     }
 
 
-    app = await this.prisma.app.findUnique({ where: { id: appName } });
-
-    return app;
+    const updateApps = await this.db.select().from(appTable).where(eq(appTable.id, appName));
+    return updateApps[0];
   };
   };
 
 
   /**
   /**
@@ -110,7 +115,8 @@ export class AppServiceClass {
    * @returns {Promise<App | null>} Returns a promise that resolves to the installed app object
    * @returns {Promise<App | null>} Returns a promise that resolves to the installed app object
    */
    */
   public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
   public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
-    let app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    const app = apps[0];
 
 
     if (app) {
     if (app) {
       await this.startApp(id);
       await this.startApp(id);
@@ -143,32 +149,39 @@ export class AppServiceClass {
         throw new Error(`App ${id} works only with exposed domain`);
         throw new Error(`App ${id} works only with exposed domain`);
       }
       }
 
 
-      if (exposed) {
-        const appsWithSameDomain = await this.prisma.app.findMany({ where: { domain, exposed: true } });
+      if (exposed && domain) {
+        const appsWithSameDomain = await this.db
+          .select()
+          .from(appTable)
+          .where(and(eq(appTable.domain, domain), eq(appTable.exposed, true)));
+
         if (appsWithSameDomain.length > 0) {
         if (appsWithSameDomain.length > 0) {
           throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
           throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
         }
         }
       }
       }
 
 
-      app = await this.prisma.app.create({ data: { id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain } });
+      const newApps = await this.db
+        .insert(appTable)
+        .values({ id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain: domain || null })
+        .returning();
+      const newApp = newApps[0];
 
 
-      if (app) {
+      if (newApp) {
         // Create env file
         // Create env file
-        generateEnvFile(app);
+        generateEnvFile(newApp);
       }
       }
 
 
       // Run script
       // Run script
       const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['install', id]);
       const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['install', id]);
 
 
       if (!success) {
       if (!success) {
-        await this.prisma.app.delete({ where: { id } });
+        await this.db.delete(appTable).where(eq(appTable.id, id));
         throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
         throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
       }
       }
     }
     }
 
 
-    app = await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
-
-    return app;
+    const updatedApp = await this.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, id)).returning();
+    return updatedApp[0];
   };
   };
 
 
   /**
   /**
@@ -201,7 +214,8 @@ export class AppServiceClass {
       throw new Error(`Domain ${domain} is not valid`);
       throw new Error(`Domain ${domain} is not valid`);
     }
     }
 
 
-    let app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    const app = apps[0];
 
 
     if (!app) {
     if (!app) {
       throw new Error(`App ${id} not found`);
       throw new Error(`App ${id} not found`);
@@ -221,18 +235,30 @@ export class AppServiceClass {
       throw new Error(`App ${id} works only with exposed domain`);
       throw new Error(`App ${id} works only with exposed domain`);
     }
     }
 
 
-    if (exposed) {
-      const appsWithSameDomain = await this.prisma.app.findMany({ where: { domain, exposed: true, id: { not: id } } });
+    if (exposed && domain) {
+      const appsWithSameDomain = await this.db
+        .select()
+        .from(appTable)
+        .where(and(eq(appTable.domain, domain), eq(appTable.exposed, true), ne(appTable.id, id)));
+
       if (appsWithSameDomain.length > 0) {
       if (appsWithSameDomain.length > 0) {
         throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
         throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
       }
       }
     }
     }
 
 
-    app = await this.prisma.app.update({ where: { id }, data: { config: form, exposed: exposed || false, domain } });
+    const updateApps = await this.db
+      .update(appTable)
+      .set({ exposed: exposed || false, domain: domain || null, config: form })
+      .where(eq(appTable.id, id))
+      .returning();
 
 
-    generateEnvFile(app);
+    const updatedApp = updateApps[0];
+
+    if (updatedApp) {
+      generateEnvFile(updatedApp);
+    }
 
 
-    return app;
+    return updatedApp;
   };
   };
 
 
   /**
   /**
@@ -243,7 +269,8 @@ export class AppServiceClass {
    * @throws {Error} - If the app cannot be found or if stopping the app failed
    * @throws {Error} - If the app cannot be found or if stopping the app failed
    */
    */
   public stopApp = async (id: string) => {
   public stopApp = async (id: string) => {
-    let app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    const app = apps[0];
 
 
     if (!app) {
     if (!app) {
       throw new Error(`App ${id} not found`);
       throw new Error(`App ${id} not found`);
@@ -253,20 +280,19 @@ export class AppServiceClass {
     generateEnvFile(app);
     generateEnvFile(app);
 
 
     // Run script
     // Run script
-    await this.prisma.app.update({ where: { id }, data: { status: 'stopping' } });
+    await this.db.update(appTable).set({ status: 'stopping' }).where(eq(appTable.id, id));
 
 
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['stop', id]);
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['stop', id]);
 
 
     if (success) {
     if (success) {
-      await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
+      await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id));
     } else {
     } else {
-      await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
+      await this.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, id));
       throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
       throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
     }
     }
 
 
-    app = await this.prisma.app.findUnique({ where: { id } });
-
-    return app;
+    const updatedApps = await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id)).returning();
+    return updatedApps[0];
   };
   };
 
 
   /**
   /**
@@ -277,7 +303,8 @@ export class AppServiceClass {
    * @throws {Error} - If the app is not found or if the app's `uninstall` script fails
    * @throws {Error} - If the app is not found or if the app's `uninstall` script fails
    */
    */
   public uninstallApp = async (id: string) => {
   public uninstallApp = async (id: string) => {
-    const app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    const app = apps[0];
 
 
     if (!app) {
     if (!app) {
       throw new Error(`App ${id} not found`);
       throw new Error(`App ${id} not found`);
@@ -289,16 +316,16 @@ export class AppServiceClass {
     ensureAppFolder(id);
     ensureAppFolder(id);
     generateEnvFile(app);
     generateEnvFile(app);
 
 
-    await this.prisma.app.update({ where: { id }, data: { status: 'uninstalling' } });
+    await this.db.update(appTable).set({ status: 'uninstalling' }).where(eq(appTable.id, id));
 
 
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['uninstall', id]);
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['uninstall', id]);
 
 
     if (!success) {
     if (!success) {
-      await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
+      await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id));
       throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
       throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
     }
     }
 
 
-    await this.prisma.app.delete({ where: { id } });
+    await this.db.delete(appTable).where(eq(appTable.id, id));
 
 
     return { id, status: 'missing', config: {} };
     return { id, status: 'missing', config: {} };
   };
   };
@@ -310,7 +337,8 @@ export class AppServiceClass {
    * @returns {Promise<App>} - The app object
    * @returns {Promise<App>} - The app object
    */
    */
   public getApp = async (id: string) => {
   public getApp = async (id: string) => {
-    let app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    let app = apps[0];
     const info = getAppInfo(id, app?.status);
     const info = getAppInfo(id, app?.status);
     const updateInfo = getUpdateInfo(id);
     const updateInfo = getUpdateInfo(id);
 
 
@@ -333,7 +361,8 @@ export class AppServiceClass {
    * @throws {Error} - If the app is not found or if the update process fails.
    * @throws {Error} - If the app is not found or if the update process fails.
    */
    */
   public updateApp = async (id: string) => {
   public updateApp = async (id: string) => {
-    let app = await this.prisma.app.findUnique({ where: { id } });
+    const apps = await this.db.select().from(appTable).where(eq(appTable.id, id));
+    const app = apps[0];
 
 
     if (!app) {
     if (!app) {
       throw new Error(`App ${id} not found`);
       throw new Error(`App ${id} not found`);
@@ -342,21 +371,21 @@ export class AppServiceClass {
     ensureAppFolder(id);
     ensureAppFolder(id);
     generateEnvFile(app);
     generateEnvFile(app);
 
 
-    await this.prisma.app.update({ where: { id }, data: { status: 'updating' } });
+    await this.db.update(appTable).set({ status: 'updating' }).where(eq(appTable.id, id));
 
 
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
     const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
 
 
     if (success) {
     if (success) {
       const appInfo = getAppInfo(app.id, app.status);
       const appInfo = getAppInfo(app.id, app.status);
 
 
-      await this.prisma.app.update({ where: { id }, data: { status: 'running', version: appInfo?.tipi_version } });
+      await this.db.update(appTable).set({ status: 'running', version: appInfo?.tipi_version }).where(eq(appTable.id, id));
     } else {
     } else {
-      await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
+      await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id));
       throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
       throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
     }
     }
 
 
-    app = await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
-    return app;
+    const updatedApps = await this.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id)).returning();
+    return updatedApps[0];
   };
   };
 
 
   /**
   /**
@@ -365,7 +394,7 @@ export class AppServiceClass {
    * @returns {Promise<App[]>} - An array of app objects
    * @returns {Promise<App[]>} - An array of app objects
    */
    */
   public installedApps = async () => {
   public installedApps = async () => {
-    const apps = await this.prisma.app.findMany({ orderBy: { id: 'asc' } });
+    const apps = await this.db.select().from(appTable).orderBy(asc(appTable.id));
 
 
     return apps
     return apps
       .map((app) => {
       .map((app) => {

+ 30 - 10
src/server/tests/apps.factory.ts

@@ -1,12 +1,14 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
-import { App, PrismaClient } from '@prisma/client';
+import { eq } from 'drizzle-orm';
 import { Architecture } from '../core/TipiConfig/TipiConfig';
 import { Architecture } from '../core/TipiConfig/TipiConfig';
 import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
 import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
 import { APP_CATEGORIES } from '../services/apps/apps.types';
 import { APP_CATEGORIES } from '../services/apps/apps.types';
+import { TestDatabase } from './test-utils';
+import { appTable, AppStatus, App } from '../db/schema';
 
 
 interface IProps {
 interface IProps {
   installed?: boolean;
   installed?: boolean;
-  status?: App['status'];
+  status?: AppStatus;
   requiredPort?: number;
   requiredPort?: number;
   randomField?: boolean;
   randomField?: boolean;
   exposed?: boolean;
   exposed?: boolean;
@@ -31,8 +33,8 @@ const createAppConfig = (props?: Partial<AppInfo>) =>
     ...props,
     ...props,
   });
   });
 
 
-const createApp = async (props: IProps, db?: PrismaClient) => {
-  const { installed = false, status = 'running', randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures, forceExpose = false } = props;
+const createApp = async (props: IProps, database: TestDatabase) => {
+  const { installed = false, status = 'running', randomField = false, exposed = false, domain = null, exposable = false, supportedArchitectures, forceExpose = false } = props;
 
 
   const categories = Object.values(APP_CATEGORIES);
   const categories = Object.values(APP_CATEGORIES);
 
 
@@ -82,17 +84,21 @@ const createApp = async (props: IProps, db?: PrismaClient) => {
   MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
   MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
 
 
   let appEntity: App = {} as App;
   let appEntity: App = {} as App;
-  if (installed && db) {
-    appEntity = await db.app.create({
-      data: {
+  if (installed) {
+    const insertedApp = await database.db
+      .insert(appTable)
+      .values({
         id: appInfo.id,
         id: appInfo.id,
         config: { TEST_FIELD: 'test' },
         config: { TEST_FIELD: 'test' },
         status,
         status,
         exposed,
         exposed,
         domain,
         domain,
         version: 1,
         version: 1,
-      },
-    });
+      })
+      .returning();
+
+    // eslint-disable-next-line prefer-destructuring
+    appEntity = insertedApp[0] as App;
 
 
     MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
     MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
     MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
     MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
@@ -103,4 +109,18 @@ const createApp = async (props: IProps, db?: PrismaClient) => {
   return { appInfo, MockFiles, appEntity };
   return { appInfo, MockFiles, appEntity };
 };
 };
 
 
-export { createApp, createAppConfig };
+const getAppById = async (id: string, database: TestDatabase) => {
+  const apps = await database.db.select().from(appTable).where(eq(appTable.id, id));
+  return apps[0] || null;
+};
+
+const updateApp = async (id: string, props: Partial<App>, database: TestDatabase) => {
+  await database.db.update(appTable).set(props).where(eq(appTable.id, id));
+};
+
+const getAllApps = async (database: TestDatabase) => {
+  const apps = await database.db.select().from(appTable);
+  return apps;
+};
+
+export { createApp, getAppById, updateApp, getAllApps, createAppConfig };