refactor: isolate database client in its own package (#1581)

* refactor: isolate database client in its own package

* refactor: bot suggestions
This commit is contained in:
Nicolas Meienberger 2024-08-08 22:18:08 +02:00 committed by GitHub
parent 52a99490b1
commit e988991bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
129 changed files with 1002 additions and 778 deletions

View File

@ -8,3 +8,4 @@ jest.config.js
/app-data
/apps
package.json
packages/**

View File

@ -18,7 +18,7 @@ module.exports = {
tsconfigRootDir: __dirname,
},
rules: {
'import/no-cycle': 2,
'import/no-cycle': 1,
'import/no-named-as-default-member': 0,
'import/no-named-as-default': 0,
'no-console': 1,

View File

@ -17,6 +17,11 @@ ENV LOCAL=${LOCAL}
RUN npm install pnpm@9.4.0 -g
RUN apk add --no-cache curl python3 make g++ git
WORKDIR /deps
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch
# ---- RUNNER BASE ----
FROM node_base AS runner_base
@ -28,12 +33,10 @@ FROM builder_base AS dashboard_builder
WORKDIR /dashboard
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch
COPY ./pnpm-workspace.yaml ./
COPY ./package*.json ./
COPY ./packages/shared ./packages/shared
COPY ./packages/db ./packages/db
COPY ./scripts ./scripts
COPY ./public ./public
@ -55,11 +58,10 @@ WORKDIR /worker
ARG TARGETARCH
ENV TARGETARCH=${TARGETARCH}
ARG DOCKER_COMPOSE_VERSION="v2.27.0"
ARG DOCKER_COMPOSE_VERSION="v2.29.1"
RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -L -o docker-binary "https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-linux-aarch64"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \
@ -70,20 +72,15 @@ RUN if [ "${TARGETARCH}" = "arm64" ]; then \
RUN chmod +x docker-binary
COPY ./pnpm-lock.yaml ./
RUN pnpm fetch --ignore-scripts
COPY ./pnpm-workspace.yaml ./
COPY ./packages/worker/package.json ./packages/worker/package.json
COPY ./packages/shared/package.json ./packages/shared/package.json
COPY ./packages/db/package.json ./packages/db/package.json
COPY ./packages/shared/package.json ./packages/shared/package.json
RUN pnpm install -r --prefer-offline
COPY ./packages ./packages
COPY ./packages/worker/build.js ./packages/worker/build.js
COPY ./packages/worker/src ./packages/worker/src
COPY ./packages/worker/package.json ./packages/worker/package.json
COPY ./packages/worker/assets ./packages/worker/assets
# Print TIPI_VERSION to the console
RUN echo "TIPI_VERSION: ${SENTRY_RELEASE}"
@ -99,6 +96,7 @@ WORKDIR /worker
COPY --from=worker_builder /worker/packages/worker/dist .
COPY --from=worker_builder /worker/packages/worker/assets ./assets
COPY --from=worker_builder /worker/packages/db/assets/migrations ./assets/migrations
COPY --from=worker_builder /worker/docker-binary /usr/local/bin/docker-compose
WORKDIR /dashboard

View File

@ -10,7 +10,7 @@ RUN apk add --no-cache python3 make g++ curl git
RUN npm install pnpm@9.4.0 pm2 -g
ARG TARGETARCH
ARG DOCKER_COMPOSE_VERSION="v2.23.3"
ARG DOCKER_COMPOSE_VERSION="v2.29.1"
ENV TARGETARCH=${TARGETARCH}
ENV NODE_ENV="development"
@ -35,12 +35,16 @@ RUN pnpm fetch --ignore-scripts
COPY ./package*.json ./
COPY ./packages/worker/package.json ./packages/worker/package.json
COPY ./packages/shared/package.json ./packages/shared/package.json
COPY ./packages/db/package.json ./packages/db/package.json
COPY ./scripts ./scripts
COPY ./public ./public
RUN pnpm install -r --prefer-offline
COPY ./packages ./packages
COPY ./packages/db/assets/migrations ./packages/worker/assets/migrations
COPY ./tsconfig.json ./tsconfig.json
COPY ./next.config.mjs ./next.config.mjs

View File

@ -44,6 +44,9 @@
}
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
},
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",

View File

@ -31,7 +31,7 @@ services:
POSTGRES_USER: tipi
POSTGRES_DB: tipi
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d tipi -U tipi']
test: ["CMD-SHELL", "pg_isready -d tipi -U tipi"]
interval: 5s
timeout: 10s
retries: 120
@ -48,7 +48,7 @@ services:
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 120
@ -71,6 +71,7 @@ services:
- ./src:/app/src
- ./packages/worker/src:/app/packages/worker/src
- ./packages/shared/src:/app/packages/shared/src
- ./packages/db/src:/app/packages/db/src
# Data
- ${RUNTIPI_MEDIA_PATH:-.}/media:/data/media
- ${RUNTIPI_STATE_PATH:-.}/state:/data/state

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { appTable } from '@/server/db/schema';
import { appTable } from '@runtipi/db';
import { setSettings } from './helpers/settings';
import { loginUser } from './fixtures/fixtures';
import { clearDatabase, db } from './helpers/db';
@ -49,7 +49,7 @@ test('logged out users can see the apps on the guest dashboard', async ({ browse
await page.goto('/');
await expect(page.getByText(/Hello World web server/)).toBeVisible();
const locator = page.locator('text=Actual Budget');
expect(locator).not.toBeVisible();
await expect(locator).not.toBeVisible();
const [newPage] = await Promise.all([context.waitForEvent('page'), await page.getByRole('link', { name: /Hello World/ }).click()]);

View File

@ -1,6 +1,6 @@
import * as argon2 from 'argon2';
import { BrowserContext, expect, Page } from '@playwright/test';
import { userTable } from '@/server/db/schema';
import { type BrowserContext, expect, type Page } from '@playwright/test';
import { userTable } from '@runtipi/db';
import { db } from '../helpers/db';
import { testUser } from '../helpers/constants';

View File

@ -1,6 +1,6 @@
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from '../../src/server/db/schema';
import * as schema from '@runtipi/db';
const connectionString = `postgresql://tipi:${process.env.POSTGRES_PASSWORD}@${process.env.SERVER_IP}:5432/tipi?connect_timeout=300`;

View File

@ -38,6 +38,7 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@runtipi/db": "workspace:^",
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"@sentry/nextjs": "^8.22.0",

19
packages/db/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "@runtipi/db",
"version": "1.0.0",
"description": "",
"main": "./src/index.ts",
"module": "./src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 0"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"drizzle-orm": "^0.32.1",
"pg": "^8.12.0"
}
}

42
packages/db/src/client.ts Normal file
View File

@ -0,0 +1,42 @@
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import type { ILogger } from '@runtipi/shared/node';
export type IDatabase = NodePgDatabase<typeof schema>;
type IConfig = {
host: string;
port: number;
username: string;
password: string;
database: string;
};
export interface IDbClient {
db: IDatabase;
}
export class DbClient {
public db: IDatabase;
private logger: ILogger;
constructor(config: IConfig, logger: ILogger) {
this.logger = logger;
const connectionString = `postgresql://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}?connect_timeout=300`;
const pool = new Pool({
connectionString,
});
pool.on('error', async (err) => {
this.logger.error('Unexpected error on idle client:', err);
});
pool.on('connect', () => {
this.logger.info('Connected to the database successfully.');
});
this.db = drizzle(pool, { schema });
}
}

3
packages/db/src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './schema';
export { Migrator, type IMigrator } from './migrator';
export { type IDbClient, type IDatabase as Database, DbClient } from './client';

View File

@ -0,0 +1,57 @@
import path from 'node:path';
import pg from 'pg';
import { migrate } from '@runtipi/postgres-migrations';
import type { ILogger } from '@runtipi/shared/node';
type MigrationParams = {
host: string;
port: number;
username: string;
password: string;
database: string;
migrationsFolder: string;
};
export interface IMigrator {
runPostgresMigrations(params: MigrationParams): Promise<void>;
}
export class Migrator implements IMigrator {
constructor(private logger: ILogger) {}
public runPostgresMigrations = async (params: MigrationParams) => {
const { database, host, username, password, port, migrationsFolder } = params;
this.logger.info('Starting database migration');
this.logger.info(`Connecting to database ${database} on ${host} as ${username} on port ${port}`);
const client = new pg.Client({ user: username, host, database, password, port: Number(port) });
await client.connect();
this.logger.info('Client connected');
try {
const { rows } = await client.query('SELECT * FROM migrations');
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
if (rows.find((row) => row.name === 'Initial1657299198975')) {
this.logger.info('Found legacy migration. Deleting table migrations');
await client.query('DROP TABLE migrations');
}
} catch (e) {
this.logger.info('Migrations table not found, creating it', e);
}
this.logger.info('Running migrations');
try {
await migrate({ client }, path.join(migrationsFolder, 'migrations'), { skipCreateMigrationTable: true });
} catch (e) {
this.logger.error('Error running migrations. Dropping table migrations and trying again', e);
await client.query('DROP TABLE migrations');
await migrate({ client }, path.join(migrationsFolder, 'migrations'), { skipCreateMigrationTable: true });
}
this.logger.info('Migration complete');
await client.end();
};
}

View File

@ -1,4 +1,4 @@
import { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb } from 'drizzle-orm/pg-core';
const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']);

30
packages/db/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"module": "preserve",
"noEmit": true,
"lib": [
"es2022"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.mjs",
"**/*.js",
"**/*.jsx"
],
"exclude": [
"node_modules"
]
}

View File

@ -3,7 +3,11 @@
"target": "es2017",
"baseUrl": ".",
"paths": {},
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -18,9 +22,19 @@
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true,
"types": ["node"],
"types": [
"node"
],
"experimentalDecorators": true
},
"include": ["**/*.ts", "**/*.tsx", "**/*.mjs", "**/*.js", "**/*.jsx"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.mjs",
"**/*.js",
"**/*.jsx"
],
"exclude": [
"node_modules"
]
}

View File

@ -1,12 +1,7 @@
module.exports = {
root: true,
plugins: ['@typescript-eslint', 'import'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'prettier',
],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript', 'prettier'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
@ -46,7 +41,7 @@ module.exports = {
'arrow-body-style': 0,
'no-underscore-dangle': 0,
'no-console': 0,
'import/no-cycle': 2,
'import/no-cycle': 1,
},
settings: {
'import/parsers': {

View File

@ -29,7 +29,7 @@ async function bundle() {
loader: {
'.node': 'copy',
},
minify: true,
minify: process.env.LOCAL !== 'true',
plugins,
};

View File

@ -38,18 +38,21 @@
},
"dependencies": {
"@hono/node-server": "^1.12.0",
"@runtipi/db": "workspace:^",
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"@sentry/integrations": "^7.114.0",
"@sentry/node": "^8.22.0",
"bullmq": "^5.12.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.32.1",
"hono": "^4.5.3",
"inversify": "^6.0.2",
"ioredis": "^5.4.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"socket.io": "^4.7.5",
"source-map-support": "^0.5.21",
"systeminformation": "^5.23.3",
"tar-fs": "^3.0.6",
"web-push": "^3.6.7",

View File

@ -1,14 +1,16 @@
import { jwt } from 'hono/jwt';
import { prettyJSON } from 'hono/pretty-json';
import { secureHeaders } from 'hono/secure-headers';
import { Hono } from 'hono';
import type { Hono } from 'hono';
import { getEnv } from './lib/environment';
import { AppExecutors, SystemExecutors } from './services';
import { SystemExecutors } from './services';
import { container } from './inversify.config';
import type { IAppExecutors } from './services/app/app.executors';
const apps = new AppExecutors();
const system = new SystemExecutors();
export const setupRoutes = (app: Hono) => {
const apps = container.get<IAppExecutors>('IAppExecutors');
app.get('/healthcheck', (c) => c.text('OK', 200));
app.use('*', prettyJSON());

View File

@ -1,6 +1,7 @@
import 'reflect-metadata';
import 'source-map-support/register';
import { SystemEvent, cleanseErrorData } from '@runtipi/shared';
import { type SystemEvent, cleanseErrorData } from '@runtipi/shared';
import path from 'node:path';
import Redis from 'ioredis';
@ -11,13 +12,16 @@ import { extraErrorDataIntegration } from '@sentry/integrations';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from '@/lib/system';
import { runPostgresMigrations } from '@/lib/migrations';
import { startWorker } from './watcher/watcher';
import { logger } from '@/lib/logger';
import { AppExecutors, RepoExecutors } from './services';
import { RepoExecutors } from './services';
import { setupRoutes } from './api';
import { DATA_DIR } from './config';
import { APP_DIR, DATA_DIR } from './config';
import { socketManager } from './lib/socket';
import { container } from './inversify.config';
import type { IAppExecutors } from './services/app/app.executors';
import type { ILogger } from '@runtipi/shared/node';
import type { IMigrator } from '@runtipi/db';
const envFile = path.join(DATA_DIR, '.env');
@ -42,6 +46,11 @@ const setupSentry = (release?: string) => {
const main = async () => {
try {
// TODO: Convert to class with injected dependencies
const logger = container.get<ILogger>('ILogger');
const migrator = container.get<IMigrator>('IMigrator');
const appExecutor = container.get<IAppExecutors>('IAppExecutors');
await logger.flush();
logger.info(`Running tipi-worker version: ${process.env.TIPI_VERSION}`);
@ -51,11 +60,7 @@ const main = async () => {
logger.info('Copying system files...');
await copySystemFiles(envMap);
if (
envMap.get('ALLOW_ERROR_MONITORING') === 'true' &&
process.env.NODE_ENV === 'production' &&
process.env.LOCAL !== 'true'
) {
if (envMap.get('ALLOW_ERROR_MONITORING') === 'true' && process.env.NODE_ENV === 'production' && process.env.LOCAL !== 'true') {
logger.info(
`Anonymous error monitoring is enabled, to disable it add "allowErrorMonitoring": false to your settings.json file. Version: ${process.env.TIPI_VERSION}`,
);
@ -119,12 +124,14 @@ const main = async () => {
await repeatQueue.close();
logger.info('Running database migrations...');
await runPostgresMigrations({
postgresHost: envMap.get('POSTGRES_HOST') as string,
postgresDatabase: envMap.get('POSTGRES_DBNAME') as string,
postgresUsername: envMap.get('POSTGRES_USERNAME') as string,
postgresPassword: envMap.get('POSTGRES_PASSWORD') as string,
postgresPort: envMap.get('POSTGRES_PORT') as string,
await migrator.runPostgresMigrations({
host: envMap.get('POSTGRES_HOST') as string,
database: envMap.get('POSTGRES_DBNAME') as string,
username: envMap.get('POSTGRES_USERNAME') as string,
password: envMap.get('POSTGRES_PASSWORD') as string,
port: Number(envMap.get('POSTGRES_PORT')),
migrationsFolder: path.join(APP_DIR, 'assets'),
});
// Set status to running
@ -138,7 +145,6 @@ const main = async () => {
await cache.quit();
// Start all apps
const appExecutor = new AppExecutors();
logger.info('Starting all apps...');
// Fire and forget
@ -153,7 +159,11 @@ const main = async () => {
});
} catch (e) {
Sentry.captureException(e);
logger.error(e);
logger.error('Failed to start');
if (e instanceof Error) {
logger.error(e.stack);
}
setTimeout(() => {
process.exit(1);

View File

@ -1,12 +1,15 @@
import 'reflect-metadata';
import { type ILogger, Logger } from '@runtipi/shared/node';
import { type IDbClient, type IMigrator, DbClient, Migrator } from '@runtipi/db';
import { Container } from 'inversify';
import path from 'path';
import path from 'node:path';
import { DATA_DIR } from './config';
import { ISocketManager, SocketManager } from './lib/socket/SocketManager';
import { type ISocketManager, SocketManager } from './lib/socket/SocketManager';
import { getEnv } from './lib/environment';
import { AppExecutors, type IAppExecutors } from './services/app/app.executors';
export function createContainer() {
const { postgresHost, postgresPort, postgresDatabase, postgresPassword, postgresUsername } = getEnv();
const container = new Container();
container.bind<ILogger>('ILogger').toDynamicValue(() => {
@ -14,6 +17,28 @@ export function createContainer() {
});
container.bind<ISocketManager>('ISocketManager').to(SocketManager).inSingletonScope();
container
.bind<IDbClient>('IDbClient')
.toDynamicValue((context) => {
return new DbClient(
{
host: postgresHost,
port: Number(postgresPort),
database: postgresDatabase,
password: postgresPassword,
username: postgresUsername,
},
context.container.get<ILogger>('ILogger'),
);
})
.inSingletonScope();
container.bind<IMigrator>('IMigrator').toDynamicValue((context) => {
return new Migrator(context.container.get<ILogger>('ILogger'));
});
container.bind<IAppExecutors>('IAppExecutors').to(AppExecutors);
return container;
}

View File

@ -1,57 +0,0 @@
import pg from 'pg';
import * as Sentry from '@sentry/node';
import { getEnv } from '../environment';
import { logger } from '../logger';
class DbClientSingleton {
private client: pg.Client | null;
constructor() {
this.client = null;
}
async connect() {
if (!this.client) {
try {
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
this.client = new pg.Client({
host: postgresHost,
database: postgresDatabase,
user: postgresUsername,
password: postgresPassword,
port: Number(postgresPort),
});
await this.client.connect();
logger.info('Database connection successfully established.');
} catch (error) {
logger.error('Failed to connect to the database:', error);
this.client = null; // Ensure client is null to retry connection on next call
throw error; // Rethrow or handle error as needed
}
}
this.client.on('error', (error) => {
Sentry.captureException(error);
logger.error('Database connection error:', error);
this.client = null;
});
return this.client;
}
async getClient() {
if (!this.client) {
await this.connect();
}
return this.client;
}
}
const dbClientSingleton = new DbClientSingleton();
export const getDbClient = async () => {
return dbClientSingleton.getClient();
};

View File

@ -1 +0,0 @@
export { getDbClient } from './db';

View File

@ -1 +0,0 @@
export { runPostgresMigrations } from './run-migration';

View File

@ -1,57 +0,0 @@
import path from 'path';
import pg from 'pg';
import { migrate } from '@runtipi/postgres-migrations';
import { logger } from '@/lib/logger';
import { APP_DIR } from '@/config/constants';
type MigrationParams = {
postgresHost: string;
postgresDatabase: string;
postgresUsername: string;
postgresPassword: string;
postgresPort: string;
};
export const runPostgresMigrations = async (params: MigrationParams) => {
const assetsFolder = path.join(APP_DIR, 'assets');
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = params;
logger.info('Starting database migration');
logger.info(`Connecting to database ${postgresDatabase} on ${postgresHost} as ${postgresUsername} on port ${postgresPort}`);
const client = new pg.Client({
user: postgresUsername,
host: postgresHost,
database: postgresDatabase,
password: postgresPassword,
port: Number(postgresPort),
});
await client.connect();
logger.info('Client connected');
try {
const { rows } = await client.query('SELECT * FROM migrations');
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
if (rows.find((row) => row.name === 'Initial1657299198975')) {
logger.info('Found legacy migration. Deleting table migrations');
await client.query('DROP TABLE migrations');
}
} catch (e) {
logger.info('Migrations table not found, creating it');
}
logger.info('Running migrations');
try {
await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true });
} catch (e) {
logger.error('Error running migrations. Dropping table migrations and trying again');
await client.query('DROP TABLE migrations');
await migrate({ client }, path.join(assetsFolder, 'migrations'), { skipCreateMigrationTable: true });
}
logger.info('Migration complete');
await client.end();
};

View File

@ -1,8 +1,8 @@
import { SocketEvent } from '@runtipi/shared';
import type { SocketEvent } from '@runtipi/shared';
import type { ILogger } from '@runtipi/shared/src/node';
import { inject, injectable } from 'inversify';
import { Server } from 'socket.io';
import { handleViewAppLogsEvent, handleViewRuntipiLogsEvent } from '../docker';
import { inject, injectable } from 'inversify';
import { ILogger } from '@runtipi/shared/src/node';
export interface ISocketManager {
init(): void;

View File

@ -1,27 +1,26 @@
import fs from 'fs';
import fs from 'node:fs';
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import path from 'node:path';
import { faker } from '@faker-js/faker';
import * as sharedNode from '@runtipi/shared/node';
import { AppExecutors } from '../app.executors';
import type { IAppExecutors } from '../app.executors';
import { createAppConfig } from '@/tests/apps.factory';
import * as dockerHelpers from '@/lib/docker';
import { getEnv } from '@/lib/environment';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { container } from 'src/inversify.config';
const { pathExists } = sharedNode;
const { appsRepoId } = getEnv();
describe('test: app executors', () => {
const appExecutors = new AppExecutors();
const appExecutors = container.get<IAppExecutors>('IAppExecutors');
describe('test: installApp()', () => {
it('should run correct compose script', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const config = createAppConfig({}, false);
// act
@ -32,10 +31,7 @@ describe('test: app executors', () => {
expect(success).toBe(true);
expect(message).toBe(`App ${config.id} installed successfully`);
expect(spy).toHaveBeenCalledWith(
config.id,
'up --detach --force-recreate --remove-orphans --pull always',
);
expect(spy).toHaveBeenCalledWith(config.id, 'up --detach --force-recreate --remove-orphans --pull always');
expect(envExists).toBe(true);
spy.mockRestore();
});
@ -94,20 +90,14 @@ describe('test: app executors', () => {
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), {
recursive: true,
});
await fs.promises.writeFile(
path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename),
'test',
);
await fs.promises.writeFile(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(APP_DATA_DIR, config.id, 'data', filename));
const data = await fs.promises.readFile(
path.join(APP_DATA_DIR, config.id, 'data', filename),
'utf-8',
);
const data = await fs.promises.readFile(path.join(APP_DATA_DIR, config.id, 'data', filename), 'utf-8');
expect(exists).toBe(true);
expect(data).toBe('test');
@ -121,20 +111,14 @@ describe('test: app executors', () => {
await fs.promises.mkdir(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data'), {
recursive: true,
});
await fs.promises.writeFile(
path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename),
'yeah',
);
await fs.promises.writeFile(path.join(DATA_DIR, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(APP_DATA_DIR, config.id, 'data', filename));
const data = await fs.promises.readFile(
path.join(APP_DATA_DIR, config.id, 'data', filename),
'utf-8',
);
const data = await fs.promises.readFile(path.join(APP_DATA_DIR, config.id, 'data', filename), 'utf-8');
expect(exists).toBe(true);
expect(data).toBe('test');
@ -142,9 +126,7 @@ describe('test: app executors', () => {
it('should handle errors gracefully', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.reject(new Error('test')));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
const config = createAppConfig();
// act
@ -169,9 +151,7 @@ describe('test: app executors', () => {
describe('test: stopApp()', () => {
it('should run correct compose script', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const config = createAppConfig();
// act
@ -186,9 +166,7 @@ describe('test: app executors', () => {
it('should handle errors gracefully', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.reject(new Error('test')));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
const config = createAppConfig();
// act
@ -204,9 +182,7 @@ describe('test: app executors', () => {
describe('test: restartApp()', () => {
it('should start and stop the app', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: 'done', stderr: '' }));
const config = createAppConfig();
// act
@ -215,19 +191,14 @@ describe('test: app executors', () => {
// assert
expect(success).toBe(true);
expect(message).toBe(`App ${config.id} restarted successfully`);
expect(spy).toHaveBeenCalledWith(
config.id,
'up --detach --force-recreate --remove-orphans --pull always',
);
expect(spy).toHaveBeenCalledWith(config.id, 'up --detach --force-recreate --remove-orphans --pull always');
expect(spy).toHaveBeenCalledWith(config.id, 'rm --force --stop');
spy.mockRestore();
});
it('should handle errors gracefully', async () => {
// arrange
const spy = vi
.spyOn(dockerHelpers, 'compose')
.mockImplementation(() => Promise.reject(new Error('test')));
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.reject(new Error('test')));
const config = createAppConfig();
// act
@ -276,10 +247,7 @@ describe('test: app executors', () => {
// assert
const exists = await pathExists(oldFolder);
const content = await fs.promises.readFile(
path.join(oldFolder, 'docker-compose.yml'),
'utf-8',
);
const content = await fs.promises.readFile(path.join(oldFolder, 'docker-compose.yml'), 'utf-8');
expect(exists).toBe(true);
expect(content).not.toBe('test');

View File

@ -1,26 +1,42 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import * as Sentry from '@sentry/node';
import { execAsync, pathExists } from '@runtipi/shared/node';
import { AppEventForm, SocketEvent, appInfoSchema, sanitizePath } from '@runtipi/shared';
import { execAsync, type ILogger, pathExists } from '@runtipi/shared/node';
import { type AppEventForm, type SocketEvent, appInfoSchema, sanitizePath } from '@runtipi/shared';
import { copyDataDir, generateEnvFile } from './app.helpers';
import { logger } from '@/lib/logger';
import { compose } from '@/lib/docker';
import { getEnv } from '@/lib/environment';
import { getDbClient } from '@/lib/db';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { getDockerCompose } from '@/config/docker-templates';
import { ArchiveManager } from '@/lib/archive/ArchiveManager';
import { socketManager } from '@/lib/socket';
import { type App, appTable, type IDbClient } from '@runtipi/db';
import { and, eq, ne } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import type { ISocketManager } from '@/lib/socket/SocketManager';
export class AppExecutors {
private readonly logger;
export interface IAppExecutors {
regenerateAppEnv(appId: string, form: AppEventForm): Promise<{ success: boolean; message: string }>;
installApp(appId: string, form: AppEventForm): Promise<{ success: boolean; message: string }>;
stopApp(appId: string, form: AppEventForm, skipEnvGeneration?: boolean): Promise<{ success: boolean; message: string }>;
restartApp(appId: string, form: AppEventForm, skipEnvGeneration?: boolean): Promise<{ success: boolean; message: string }>;
startApp(appId: string, form: AppEventForm, skipEnvGeneration?: boolean): Promise<{ success: boolean; message: string }>;
uninstallApp(appId: string, form: AppEventForm): Promise<{ success: boolean; message: string }>;
resetApp(appId: string, form: AppEventForm): Promise<{ success: boolean; message: string }>;
updateApp(appId: string, form: AppEventForm, performBackup: boolean): Promise<{ success: boolean; message: string }>;
startAllApps(forceStartAll?: boolean): Promise<void>;
backupApp(appId: string): Promise<{ success: boolean; message: string }>;
restoreApp(appId: string, filename: string): Promise<{ success: boolean; message: string }>;
}
@injectable()
export class AppExecutors implements IAppExecutors {
private archiveManager: ArchiveManager;
constructor() {
this.logger = logger;
constructor(
@inject('ILogger') private logger: ILogger,
@inject('IDbClient') private dbClient: IDbClient,
@inject('ISocketManager') private socketManager: ISocketManager,
) {
this.archiveManager = new ArchiveManager();
}
@ -35,7 +51,7 @@ export class AppExecutors {
});
if (err instanceof Error) {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event,
data: { appId, error: err.message, appStatus: newStatus },
@ -44,7 +60,7 @@ export class AppExecutors {
return { success: false, message: err.message };
}
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event,
data: { appId, error: String(err), appStatus: newStatus },
@ -86,19 +102,14 @@ export class AppExecutors {
if (await pathExists(path.join(repoPath, 'docker-compose.json'))) {
try {
// Generate docker-compose.yml file
const rawComposeConfig = await fs.promises.readFile(
path.join(repoPath, 'docker-compose.json'),
'utf-8',
);
const rawComposeConfig = await fs.promises.readFile(path.join(repoPath, 'docker-compose.json'), 'utf-8');
const jsonComposeConfig = JSON.parse(rawComposeConfig);
const composeFile = getDockerCompose(jsonComposeConfig.services, form);
await fs.promises.writeFile(dockerFilePath, composeFile);
} catch (err) {
this.logger.error(
`Error generating docker-compose.yml file for app ${appId}. Falling back to default docker-compose.yml`,
);
this.logger.error(`Error generating docker-compose.yml file for app ${appId}. Falling back to default docker-compose.yml`);
this.logger.error(err);
Sentry.captureException(err);
}
@ -117,7 +128,7 @@ export class AppExecutors {
await this.ensureAppDir(appId, form);
await generateEnvFile(appId, form);
await socketManager.emit({ type: 'app', event: 'generate_env_success', data: { appId } });
await this.socketManager.emit({ type: 'app', event: 'generate_env_success', data: { appId } });
return { success: true, message: `App ${appId} env file regenerated successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'generate_env_error');
@ -131,16 +142,14 @@ export class AppExecutors {
*/
public installApp = async (appId: string, form: AppEventForm) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'installing' },
});
if (process.getuid && process.getgid) {
this.logger.info(
`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`,
);
this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`);
} else {
this.logger.info(`Installing app ${appId}. No User ID or Group ID found.`);
}
@ -150,9 +159,7 @@ export class AppExecutors {
const { appDirPath, repoPath, appDataDirPath } = this.getAppPaths(appId);
// Check if app exists in repo
const apps = await fs.promises.readdir(
path.join(DATA_DIR, 'repos', sanitizePath(appsRepoId), 'apps'),
);
const apps = await fs.promises.readdir(path.join(DATA_DIR, 'repos', sanitizePath(appsRepoId), 'apps'));
if (!apps.includes(appId)) {
this.logger.error(`App ${appId} not found in repo ${appsRepoId}`);
@ -193,7 +200,7 @@ export class AppExecutors {
this.logger.info(`Docker-compose up for app ${appId} finished`);
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'install_success',
data: { appId, appStatus: 'running' },
@ -220,7 +227,7 @@ export class AppExecutors {
return { success: true, message: `App ${appId} is not an app. Skipping...` };
}
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'stopping' },
@ -237,14 +244,14 @@ export class AppExecutors {
this.logger.info(`App ${appId} stopped`);
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'stop_success',
data: { appId, appStatus: 'stopped' },
});
const client = await getDbClient();
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['stopped', appId]);
await this.dbClient.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, appId));
return { success: true, message: `App ${appId} stopped successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'stop_error', 'running');
@ -261,7 +268,7 @@ export class AppExecutors {
return { success: true, message: `App ${appId} is not an app. Skipping...` };
}
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'restarting' },
@ -293,7 +300,7 @@ export class AppExecutors {
this.logger.info(`App ${appId} restarted`);
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'restart_success',
data: { appId, appStatus: 'running' },
@ -307,7 +314,7 @@ export class AppExecutors {
public startApp = async (appId: string, form: AppEventForm, skipEnvGeneration = false) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'starting' },
@ -326,14 +333,13 @@ export class AppExecutors {
this.logger.info(`App ${appId} started`);
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'start_success',
data: { appId, appStatus: 'running' },
});
const client = await getDbClient();
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['running', appId]);
await this.dbClient.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, appId));
return { success: true, message: `App ${appId} started successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'start_error', 'stopped');
@ -342,7 +348,7 @@ export class AppExecutors {
public uninstallApp = async (appId: string, form: AppEventForm) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'uninstalling' },
@ -378,14 +384,14 @@ export class AppExecutors {
this.logger.info(`App ${appId} uninstalled`);
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'uninstall_success',
data: { appId, appStatus: 'missing' },
});
const client = await getDbClient();
await client?.query(`DELETE FROM app WHERE id = $1`, [appId]);
await this.dbClient.db.delete(appTable).where(eq(appTable.id, appId));
return { success: true, message: `App ${appId} uninstalled successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'uninstall_error', 'stopped');
@ -394,7 +400,7 @@ export class AppExecutors {
public resetApp = async (appId: string, form: AppEventForm) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'resetting' },
@ -410,9 +416,7 @@ export class AppExecutors {
await compose(appId, 'down --remove-orphans --volumes');
} catch (err) {
if (err instanceof Error && err.message.includes('conflict')) {
this.logger.warn(
`Could not reset app ${appId}. Most likely there have been made changes to the compose file.`,
);
this.logger.warn(`Could not reset app ${appId}. Most likely there have been made changes to the compose file.`);
} else {
throw err;
}
@ -440,14 +444,14 @@ export class AppExecutors {
this.logger.info(`Running docker-compose up for app ${appId}`);
await compose(appId, 'up -d');
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'reset_success',
data: { appId, appStatus: 'running' },
});
const client = await getDbClient();
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['running', appId]);
await this.dbClient.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, appId));
return { success: true, message: `App ${appId} reset successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'reset_error', 'stopped');
@ -461,7 +465,7 @@ export class AppExecutors {
await this.backupApp(appId);
}
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'updating' },
@ -476,9 +480,7 @@ export class AppExecutors {
await compose(appId, 'up --detach --force-recreate --remove-orphans');
await compose(appId, 'down --rmi all --remove-orphans');
} catch (err) {
logger.warn(
`App ${appId} has likely a broken docker-compose.yml file. Continuing with update...`,
);
this.logger.warn(`App ${appId} has likely a broken docker-compose.yml file. Continuing with update...`);
}
this.logger.info(`Deleting folder ${appDirPath}`);
@ -491,7 +493,7 @@ export class AppExecutors {
await compose(appId, 'pull');
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'update_success',
data: { appId, appStatus: 'stopped' },
@ -508,35 +510,32 @@ export class AppExecutors {
*/
public startAllApps = async (forceStartAll = false) => {
try {
const client = await getDbClient();
let rows: { id: string; config: Record<string, unknown> }[] = [];
let apps: App[] = [];
if (!forceStartAll) {
// Get all apps with status running
const result = await client?.query(`SELECT * FROM app WHERE status = 'running'`);
rows = result?.rows || [];
apps = await this.dbClient.db.select().from(appTable).where(eq(appTable.status, 'running'));
} else {
// Get all apps
const result = await client?.query(`SELECT * FROM app`);
rows = result?.rows || [];
apps = await this.dbClient.db.select().from(appTable);
}
// Update all apps with status different than running or stopped to stopped
await client?.query(
`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`,
);
await this.dbClient.db
.update(appTable)
.set({ status: 'stopped' })
.where(and(ne(appTable.status, 'running'), ne(appTable.status, 'stopped'), ne(appTable.status, 'missing')));
// Start all apps
for (const row of rows) {
for (const row of apps) {
const { id, config } = row;
const { success } = await this.startApp(id, config as AppEventForm);
if (!success) {
this.logger.error(`Error starting app ${id}`);
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['stopped', id]);
await this.dbClient.db.update(appTable).set({ status: 'stopped' }).where(eq(appTable.id, id));
} else {
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['running', id]);
await this.dbClient.db.update(appTable).set({ status: 'running' }).where(eq(appTable.id, id));
}
}
} catch (err) {
@ -546,7 +545,7 @@ export class AppExecutors {
public backupApp = async (appId: string) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'backing_up' },
@ -578,11 +577,7 @@ export class AppExecutors {
// Check if the user config folder exists and if it does copy it too
if (await pathExists(path.join(DATA_DIR, 'user-config', appId))) {
await fs.promises.cp(
path.join(DATA_DIR, 'user-config', appId),
path.join(tempDir, 'user-config'),
{ recursive: true },
);
await fs.promises.cp(path.join(DATA_DIR, 'user-config', appId), path.join(tempDir, 'user-config'), { recursive: true });
}
this.logger.info('Creating archive...');
@ -594,10 +589,7 @@ export class AppExecutors {
// Move the archive to the backup directory
await fs.promises.mkdir(backupDir, { recursive: true });
await fs.promises.cp(
path.join(tempDir, `${backupName}.tar.gz`),
path.join(backupDir, `${backupName}.tar.gz`),
);
await fs.promises.cp(path.join(tempDir, `${backupName}.tar.gz`), path.join(backupDir, `${backupName}.tar.gz`));
// Remove the temp backup folder
await fs.promises.rm(tempDir, { force: true, recursive: true });
@ -605,7 +597,7 @@ export class AppExecutors {
this.logger.info('Backup completed!');
// Done
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'backup_success',
data: { appId, appStatus: 'stopped' },
@ -618,7 +610,7 @@ export class AppExecutors {
public restoreApp = async (appId: string, filename: string) => {
try {
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'status_change',
data: { appId, appStatus: 'restoring' },
@ -627,7 +619,6 @@ export class AppExecutors {
const { appDataDirPath, appDirPath } = this.getAppPaths(appId);
const restoreDir = path.join('/tmp', appId);
const archive = path.join(DATA_DIR, 'backups', appId, filename);
const client = await getDbClient();
this.logger.info('Restoring app from backup...');
@ -666,11 +657,7 @@ export class AppExecutors {
// Copy user config foler if it exists
if (await pathExists(path.join(restoreDir, 'user-config'))) {
await fs.promises.cp(
path.join(restoreDir, 'user-config'),
path.join(DATA_DIR, 'user-config', appId),
{ recursive: true },
);
await fs.promises.cp(path.join(restoreDir, 'user-config'), path.join(DATA_DIR, 'user-config', appId), { recursive: true });
}
// Delete restore folder
@ -681,15 +668,13 @@ export class AppExecutors {
encoding: 'utf-8',
});
const configParsed = appInfoSchema.safeParse(JSON.parse(configFileRaw));
await client?.query(`UPDATE app SET version = $1 WHERE id = $2`, [
configParsed.data?.tipi_version,
appId,
]);
await this.dbClient.db.update(appTable).set({ version: configParsed.data?.tipi_version }).where(eq(appTable.id, appId));
this.logger.info(`App ${appId} restored!`);
// Done
await socketManager.emit({
await this.socketManager.emit({
type: 'app',
event: 'restore_success',
data: { appId, appStatus: 'stopped' },

View File

@ -1,21 +1,13 @@
import { eventSchema } from '@runtipi/shared';
import { Worker } from 'bullmq';
import { AppExecutors, RepoExecutors } from '@/services';
import { RepoExecutors } from '@/services';
import { logger } from '@/lib/logger';
import { getEnv } from '@/lib/environment';
import { container } from 'src/inversify.config';
import type { IAppExecutors } from 'src/services/app/app.executors';
const {
installApp,
resetApp,
startApp,
stopApp,
restartApp,
uninstallApp,
updateApp,
regenerateAppEnv,
backupApp,
restoreApp,
} = new AppExecutors();
const { installApp, resetApp, startApp, stopApp, restartApp, uninstallApp, updateApp, regenerateAppEnv, backupApp, restoreApp } =
container.get<IAppExecutors>('IAppExecutors');
const { cloneRepo, pullRepo } = new RepoExecutors();
const runCommand = async (jobData: unknown) => {

View File

@ -1,3 +1,4 @@
import 'reflect-metadata';
import fs from 'node:fs';
import path from 'node:path';
import { vi, beforeEach } from 'vitest';

View File

@ -44,6 +44,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.0
version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@runtipi/db':
specifier: workspace:^
version: link:packages/db
'@runtipi/postgres-migrations':
specifier: ^5.3.0
version: 5.3.0
@ -340,6 +343,21 @@ importers:
specifier: ^3.0.2
version: 3.0.2
packages/db:
dependencies:
'@runtipi/postgres-migrations':
specifier: ^5.3.0
version: 5.3.0
'@runtipi/shared':
specifier: workspace:^
version: link:../shared
drizzle-orm:
specifier: ^0.32.1
version: 0.32.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(pg@8.12.0)(react@18.3.1)
pg:
specifier: ^8.12.0
version: 8.12.0
packages/shared:
dependencies:
fs-extra:
@ -390,6 +408,9 @@ importers:
'@hono/node-server':
specifier: ^1.12.0
version: 1.12.0
'@runtipi/db':
specifier: workspace:^
version: link:../db
'@runtipi/postgres-migrations':
specifier: ^5.3.0
version: 5.3.0
@ -408,6 +429,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.4.5
drizzle-orm:
specifier: ^0.32.1
version: 0.32.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(pg@8.12.0)(react@18.3.1)
hono:
specifier: ^4.5.3
version: 4.5.3
@ -426,6 +450,9 @@ importers:
socket.io:
specifier: ^4.7.5
version: 4.7.5
source-map-support:
specifier: ^0.5.21
version: 0.5.21
systeminformation:
specifier: ^5.23.3
version: 5.23.3

View File

@ -1,12 +1,12 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import type { IAuthQueries } from '@/server/queries/auth/auth.queries';
import { LoginContainer } from './components/LoginContainer';
import { container } from 'src/inversify.config';
export default async function LoginPage() {
const authQueries = new AuthQueries(db);
const authQueries = container.get<IAuthQueries>('IAuthQueries');
const isConfigured = await authQueries.getFirstOperator();
if (!isConfigured) {

View File

@ -1,9 +1,9 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import type { IAuthQueries } from '@/server/queries/auth/auth.queries';
import { RegisterContainer } from './components/RegisterContainer';
import { container } from 'src/inversify.config';
export default async function LoginPage() {
const user = await getUserFromCookie();
@ -11,7 +11,7 @@ export default async function LoginPage() {
redirect('/dashboard');
}
const authQueries = new AuthQueries(db);
const authQueries = container.get<IAuthQueries>('IAuthQueries');
const isConfigured = await authQueries.getFirstOperator();
if (isConfigured) {

View File

@ -1,10 +1,11 @@
import React from 'react';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { getTranslator } from '@/lib/get-translator';
import { ResetPasswordContainer } from './components/ResetPasswordContainer';
import { container } from 'src/inversify.config';
import type { IAuthService } from '@/server/services/auth/auth.service';
export default async function ResetPasswordPage() {
const authService = new AuthServiceClass();
const authService = container.get<IAuthService>('IAuthService');
const isRequested = await authService.checkPasswordChangeRequest();
if (isRequested) {

View File

@ -12,7 +12,8 @@ import {
IconX,
IconRotateClockwise,
} from '@tabler/icons-react';
import React, { Fragment } from 'react';
import type React from 'react';
import { Fragment } from 'react';
import { useTranslations } from 'next-intl';
import {
@ -24,7 +25,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
import { Button, type ButtonProps } from '@/components/ui/Button';
import { GetAppCommand } from '@/server/services/app-catalog/commands';
import type { GetAppCommand } from '@/server/services/app-catalog/commands';
import { InstallModal } from '../InstallModal';
import { useDisclosure } from '@/client/hooks/useDisclosure';
import { useAction } from 'next-safe-action/hooks';

View File

@ -1,7 +1,7 @@
import React from 'react';
import { TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslations } from 'next-intl';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { useRouter } from 'next/navigation';
interface IProps {

View File

@ -1,17 +1,18 @@
import React, { useEffect } from 'react';
import type React from 'react';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Tooltip } from 'react-tooltip';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import { type FormField, type AppInfo } from '@runtipi/shared';
import type { FormField, AppInfo } from '@runtipi/shared';
import { Switch } from '@/components/ui/Switch';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { validateAppConfig } from '../../utils/validators';
import { InstallFormField } from './InstallFormField';
import { FormValues } from './InstallForm.types';
import type { FormValues } from './InstallForm.types';
import { useClientSettings } from '@/hooks/useClientSettings';
interface IProps {
@ -50,9 +51,9 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
useEffect(() => {
if (initialValues && !isDirty) {
Object.entries(initialValues).forEach(([key, value]) => {
for (const [key, value] of Object.entries(initialValues)) {
setValue(key, value as string);
});
}
}
}, [initialValues, isDirty, setValue]);
@ -164,11 +165,11 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
const validate = (values: FormValues) => {
const validationErrors = validateAppConfig(values, formFields);
Object.entries(validationErrors).forEach(([key, value]) => {
for (const [key, value] of Object.entries(validationErrors)) {
if (value) {
setError(key, { message: value });
}
});
}
if (Object.keys(validationErrors).length === 0) {
onSubmit(values);

View File

@ -1,9 +1,9 @@
import React from 'react';
import type React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import type { AppInfo } from '@runtipi/shared';
import { ScrollArea } from '@/components/ui/ScrollArea';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { InstallForm, type FormValues } from '../InstallForm';
import { useAction } from 'next-safe-action/hooks';
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';

View File

@ -1,18 +1,18 @@
import { CustomLinksServiceClass } from '@/server/services/custom-links/custom-links.service';
import { db } from '@/server/db';
import React from 'react';
import { Metadata } from 'next';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { getTranslator } from '@/lib/get-translator';
import { AppTile } from '@/components/AppTile';
import Link from 'next/link';
import { Link as CustomLink } from '@/server/db/schema';
import { Link as CustomLink } from '@runtipi/db';
import clsx from 'clsx';
import { LinkTile } from '@/components/LinkTile/LinkTile';
import { EmptyPage } from '../../components/EmptyPage';
import styles from './page.module.css';
import { AddLinkButton } from '../components/AddLink/AddLinkButton';
import { appCatalog } from '@/server/services/app-catalog/app-catalog.service';
import { container } from 'src/inversify.config';
import { ICustomLinksService } from '@/server/services/custom-links/custom-links.service';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslator();
@ -26,7 +26,7 @@ export default async function Page() {
const installedApps = await appCatalog.executeCommand('getInstalledApps');
const user = await getUserFromCookie();
const linksService = new CustomLinksServiceClass(db);
const linksService = container.get<ICustomLinksService>('ICustomLinksService');
const customLinks = await linksService.getLinks(user?.id);
const renderApp = (app: (typeof installedApps)[number]) => {

View File

@ -1,14 +1,16 @@
'use server';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { revalidatePath } from 'next/cache';
import { publicActionClient } from '@/lib/safe-action';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
/**
* Given that a password change request has been made, cancels the password change request.
*/
export const cancelResetPasswordAction = publicActionClient.action(async () => {
await AuthServiceClass.cancelPasswordChangeRequest();
const authService = container.get<IAuthService>('IAuthService');
await authService.cancelPasswordChangeRequest();
revalidatePath('/reset-password');

View File

@ -1,11 +1,12 @@
'use server';
import { CustomLinksServiceClass } from '@/server/services/custom-links/custom-links.service';
import { linkSchema } from '@runtipi/shared';
import { authActionClient } from '@/lib/safe-action';
import { container } from 'src/inversify.config';
import type { ICustomLinksService } from '@/server/services/custom-links/custom-links.service';
export const addLinkAction = authActionClient.schema(linkSchema).action(async ({ parsedInput: link, ctx }) => {
const linksService = new CustomLinksServiceClass();
const linksService = container.get<ICustomLinksService>('ICustomLinksService');
const linkResponse = await linksService.add(link, ctx.user.id);
return { success: true, link: linkResponse };

View File

@ -1,11 +1,12 @@
'use server';
import { authActionClient } from '@/lib/safe-action';
import type { ICustomLinksService } from '@/server/services/custom-links/custom-links.service';
import { container } from 'src/inversify.config';
import { z } from 'zod';
import { CustomLinksServiceClass } from '@/server/services/custom-links/custom-links.service';
export const deleteLinkAction = authActionClient.schema(z.number()).action(async ({ parsedInput: linkId, ctx: { user } }) => {
const linksService = new CustomLinksServiceClass();
const linksService = container.get<ICustomLinksService>('ICustomLinksService');
await linksService.delete(linkId, user.id);
return { success: true };

View File

@ -1,11 +1,12 @@
'use server';
import { linkSchema } from '@runtipi/shared';
import { CustomLinksServiceClass } from '@/server/services/custom-links/custom-links.service';
import { authActionClient } from '@/lib/safe-action';
import { container } from 'src/inversify.config';
import type { ICustomLinksService } from '@/server/services/custom-links/custom-links.service';
export const editLinkAction = authActionClient.schema(linkSchema).action(async ({ parsedInput: link, ctx: { user } }) => {
const linksService = new CustomLinksServiceClass();
const linksService = container.get<ICustomLinksService>('ICustomLinksService');
const linkResponse = await linksService.edit(link, user.id);
return { success: true, link: linkResponse };

View File

@ -1,10 +1,10 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { revalidatePath } from 'next/cache';
import { publicActionClient } from '@/lib/safe-action';
import { container } from 'src/inversify.config';
const input = z.object({
username: z.string(),
@ -16,7 +16,7 @@ const input = z.object({
* if that user has 2FA enabled.
*/
export const loginAction = publicActionClient.schema(input).action(async ({ parsedInput: { username, password } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
const { totpSessionId } = await authService.login({ username, password });

View File

@ -1,10 +1,10 @@
'use server';
import { cookies } from 'next/headers';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@/lib/safe-action';
import { container } from 'src/inversify.config';
import type { IAuthService } from '@/server/services/auth/auth.service';
/**
* Logs out the current user making the request.
@ -19,7 +19,7 @@ export const logoutAction = authActionClient.action(async () => {
};
}
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.logout(sessionCookie.value);
revalidatePath('/');

View File

@ -1,10 +1,10 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { publicActionClient } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { container } from 'src/inversify.config';
import type { IAuthService } from '@/server/services/auth/auth.service';
const input = z.object({
username: z.string(),
@ -15,7 +15,7 @@ const input = z.object({
* Given a username and password, registers the user and logs them in.
*/
export const registerAction = publicActionClient.schema(input).action(async ({ parsedInput: { username, password } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
const result = await authService.register({ username, password });

View File

@ -1,9 +1,9 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { publicActionClient } from '@/lib/safe-action';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
const input = z.object({
newPassword: z.string(),
@ -13,7 +13,7 @@ const input = z.object({
* Given that a password change request has been made, changes the password of the first operator.
*/
export const resetPasswordAction = publicActionClient.schema(input).action(async ({ parsedInput: { newPassword } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
const { email } = await authService.changeOperatorPassword({ newPassword });
return { success: true, email };

View File

@ -2,8 +2,8 @@
import { z } from 'zod';
import { authActionClient } from '@/lib/safe-action';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
const input = z.object({ currentPassword: z.string(), newPassword: z.string() });
@ -13,7 +13,7 @@ const input = z.object({ currentPassword: z.string(), newPassword: z.string() })
export const changePasswordAction = authActionClient
.schema(input)
.action(async ({ parsedInput: { currentPassword, newPassword }, ctx: { user } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.changePassword({ userId: user.id, currentPassword, newPassword });

View File

@ -2,8 +2,8 @@
import { z } from 'zod';
import { authActionClient } from '@/lib/safe-action';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { container } from 'src/inversify.config';
import type { IAuthService } from '@/server/services/auth/auth.service';
const input = z.object({ newUsername: z.string().email(), password: z.string() });
@ -11,7 +11,7 @@ const input = z.object({ newUsername: z.string().email(), password: z.string() }
* Given the current password and a new username, change the username of the current user.
*/
export const changeUsernameAction = authActionClient.schema(input).action(async ({ parsedInput: { newUsername, password }, ctx: { user } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.changeUsername({ userId: user.id, newUsername, password });

View File

@ -1,10 +1,10 @@
'use server';
import { z } from 'zod';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@/lib/safe-action';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
const input = z.object({ password: z.string() });
@ -12,7 +12,7 @@ const input = z.object({ password: z.string() });
* Given a valid user password, disable TOTP for the user
*/
export const disableTotpAction = authActionClient.schema(input).action(async ({ parsedInput: { password }, ctx: { user } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.disableTotp({ userId: user.id, password });
revalidatePath('/settings');

View File

@ -1,9 +1,9 @@
'use server';
import { z } from 'zod';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { authActionClient } from '@/lib/safe-action';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
const input = z.object({ password: z.string() });
@ -11,7 +11,7 @@ const input = z.object({ password: z.string() });
* Given user's password, return the TOTP URI and key
*/
export const getTotpUriAction = authActionClient.schema(input).action(async ({ parsedInput: { password }, ctx: { user } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
const { key, uri } = await authService.getTotpUri({ userId: user.id, password });
return { success: true, key, uri };

View File

@ -1,10 +1,10 @@
'use server';
import { z } from 'zod';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@/lib/safe-action';
import type { IAuthService } from '@/server/services/auth/auth.service';
import { container } from 'src/inversify.config';
const input = z.object({ totpCode: z.string() });
@ -12,7 +12,7 @@ const input = z.object({ totpCode: z.string() });
* Given a valid user's TOTP code, activate TOTP for the user
*/
export const setupTotpAction = authActionClient.schema(input).action(async ({ parsedInput: { totpCode }, ctx: { user } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.setupTotp({ userId: user.id, totpCode });
revalidatePath('/settings');

View File

@ -1,10 +1,10 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { publicActionClient } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { container } from 'src/inversify.config';
import type { IAuthService } from '@/server/services/auth/auth.service';
const input = z.object({
totpCode: z.string(),
@ -12,7 +12,7 @@ const input = z.object({
});
export const verifyTotpAction = publicActionClient.schema(input).action(async ({ parsedInput: { totpSessionId, totpCode } }) => {
const authService = new AuthServiceClass(db);
const authService = container.get<IAuthService>('IAuthService');
await authService.verifyTotp({ totpSessionId, totpCode });
revalidatePath('/login');

View File

@ -4,7 +4,7 @@ import React from 'react';
import { type ReactNode, createContext, useRef } from 'react';
import { createAppStatusStore } from './app-status-store';
import type { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
export type AppStatusStoreApi = ReturnType<typeof createAppStatusStore>;

View File

@ -1,4 +1,4 @@
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { createStore } from 'zustand/vanilla';
export type AppStatusState = {

View File

@ -1,15 +1,16 @@
'use client';
import React from 'react';
import { AbstractIntlMessages, NextIntlClientProvider } from 'next-intl';
import 'reflect-metadata';
import type React from 'react';
import { type AbstractIntlMessages, NextIntlClientProvider } from 'next-intl';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './ThemeProvider';
import { SocketProvider } from './SocketProvider/SocketProvider';
import type { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { AppStatusStoreProvider } from './AppStatusProvider/app-status-provider';
import { ClientSettingsStoreProvider } from './ClientSettingsProvider/ClientSettingsProvider';
import { settingsSchema } from '@runtipi/shared';
import { z } from 'zod';
import type { settingsSchema } from '@runtipi/shared';
import type { z } from 'zod';
type Props = {
children: React.ReactNode;

View File

@ -1,4 +1,5 @@
import React from 'react';
import 'reflect-metadata';
import type React from 'react';
import type { Metadata } from 'next';
import { getLocale, getMessages } from 'next-intl/server';
@ -13,7 +14,7 @@ import { Toaster } from 'react-hot-toast';
import { ClientProviders } from './components/ClientProviders';
import { CookiesProvider } from 'next-client-cookies/server';
import { appCatalog } from '@/server/services/app-catalog/app-catalog.service';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
export const metadata: Metadata = {
title: 'Tipi',

View File

@ -1,14 +1,14 @@
import React from 'react';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { redirect } from 'next/navigation';
import { db } from '@/server/db';
import { appCatalog } from '@/server/services/app-catalog/app-catalog.service';
import { TipiConfig } from '@/server/core/TipiConfig';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import type { IAuthQueries } from '@/server/queries/auth/auth.queries';
import { UnauthenticatedPage } from '@/components/UnauthenticatedPage';
import { headers } from 'next/headers';
import { GuestDashboardApps } from './components/GuestDashboardApps';
import { EmptyPage } from './components/EmptyPage';
import { container } from 'src/inversify.config';
export default async function RootPage() {
const { guestDashboard } = TipiConfig.getConfig();
@ -33,7 +33,7 @@ export default async function RootPage() {
);
}
const authQueries = new AuthQueries(db);
const authQueries = container.get<IAuthQueries>('IAuthQueries');
const isConfigured = await authQueries.getFirstOperator();

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import React from 'react';
import type React from 'react';
import { Tooltip } from 'react-tooltip';
import { useTranslations } from 'next-intl';
import styles from './AppStatus.module.scss';

View File

@ -1,13 +1,13 @@
'use client';
import React from 'react';
import type React from 'react';
import { IconTrash, IconEdit } from '@tabler/icons-react';
import { useDisclosure } from '@/client/hooks/useDisclosure';
import { ContextMenu, ContextMenuItem, ContextMenuContent, ContextMenuTrigger } from '@/client/components/ui/ContextMenu/ContextMenu';
import { AddLinkModal } from 'src/app/(dashboard)/components/AddLink/AddLinkModal';
import { DeleteLinkModal } from 'src/app/(dashboard)/components/AddLink/DeleteLinkModal';
import { LinkInfo } from '@runtipi/shared';
import { Link } from '@/server/db/schema';
import type { LinkInfo } from '@runtipi/shared';
import type { Link } from '@runtipi/db';
import { useTranslations } from 'next-intl';
import { AppLogo } from '../AppLogo';

View File

@ -4,15 +4,49 @@ import { type ILogger, Logger } from '@runtipi/shared/node';
import { Container } from 'inversify';
import path from 'node:path';
import { DATA_DIR } from './config';
import { DbClient, type IDbClient } from '@runtipi/db';
import { TipiConfig } from './server/core/TipiConfig';
import { AppQueries, type IAppQueries } from './server/queries/apps/apps.queries';
import { AuthQueries, type IAuthQueries } from './server/queries/auth/auth.queries';
import { LinkQueries, type ILinkQueries } from './server/queries/links/links.queries';
import { CustomLinksService, type ICustomLinksService } from './server/services/custom-links/custom-links.service';
import { AuthService, type IAuthService } from './server/services/auth/auth.service';
export function createContainer() {
const container = new Container();
const { postgresHost, postgresPort, postgresDatabase, postgresPassword, postgresUsername } = TipiConfig.getConfig();
container.bind<ILogger>('ILogger').toDynamicValue(() => {
return new Logger('dashboard', path.join(DATA_DIR, 'logs'));
});
container.bind<ITipiCache>('ITipiCache').to(TipiCache).inSingletonScope();
container
.bind<IDbClient>('IDbClient')
.toDynamicValue((context) => {
return new DbClient(
{
host: postgresHost,
port: Number(postgresPort),
database: postgresDatabase,
password: postgresPassword,
username: postgresUsername,
},
context.container.get<ILogger>('ILogger'),
);
})
.inSingletonScope();
// Repositories
container.bind<IAppQueries>('IAppQueries').to(AppQueries);
container.bind<IAuthQueries>('IAuthQueries').to(AuthQueries);
container.bind<ILinkQueries>('ILinkQueries').to(LinkQueries);
// Services
container.bind<ICustomLinksService>('ICustomLinksService').to(CustomLinksService);
container.bind<IAuthService>('IAuthService').to(AuthService);
return container;
}

View File

@ -1,8 +1,8 @@
import { v4 } from 'uuid';
import { cookies } from 'next/headers';
import { tipiCache } from '../core/TipiCache';
import { db } from '../db';
import { AuthQueries } from '../queries/auth/auth.queries';
import type { IAuthQueries } from '../queries/auth/auth.queries';
import { container } from 'src/inversify.config';
import type { ITipiCache } from '../core/TipiCache/TipiCache';
const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
const COOKIE_NAME = 'tipi.sid';
@ -12,6 +12,7 @@ export const generateSessionId = (prefix: string) => {
};
export const setSession = async (sessionId: string, userId: string) => {
const tipiCache = container.get<ITipiCache>('ITipiCache');
const cookieStore = cookies();
cookieStore.set(COOKIE_NAME, sessionId, { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
@ -22,7 +23,8 @@ export const setSession = async (sessionId: string, userId: string) => {
};
export const getUserFromCookie = async () => {
const authQueries = new AuthQueries(db);
const tipiCache = container.get<ITipiCache>('ITipiCache');
const authQueries = container.get<IAuthQueries>('IAuthQueries');
const cookieStore = cookies();
const sessionId = cookieStore.get('tipi.sid');

View File

@ -1,5 +1,5 @@
import { Queue, QueueEvents } from 'bullmq';
import { eventResultSchema, eventSchema, SystemEvent } from '@runtipi/shared';
import { eventResultSchema, eventSchema, type SystemEvent } from '@runtipi/shared';
import { TipiConfig } from '@/server/core/TipiConfig';
import { Logger } from '../Logger';

View File

@ -1,7 +1,7 @@
import { createClient, RedisClientType } from 'redis';
import { createClient, type RedisClientType } from 'redis';
import { TipiConfig } from '../TipiConfig';
import { inject, injectable } from 'inversify';
import { ILogger } from '@runtipi/shared/node';
import type { ILogger } from '@runtipi/shared/node';
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
@ -12,10 +12,11 @@ export interface ITipiCache {
getByPrefix(prefix: string): Promise<Array<{ key: string; val: string | null }>>;
close(): Promise<string>;
ttl(key: string): Promise<number>;
clear(): Promise<number[]>;
}
@injectable()
export class TipiCache {
export class TipiCache implements ITipiCache {
private client: RedisClientType;
constructor(@inject('ILogger') private logger: ILogger) {
@ -80,4 +81,15 @@ export class TipiCache {
const client = await this.getClient();
return client.ttl(key);
}
public async clear() {
const client = await this.getClient();
try {
const keys = await client.keys('*');
return Promise.all(keys.map((key) => client.del(key)));
} catch (error) {
this.logger.error('Failed to clear cache', error);
throw error;
}
}
}

View File

@ -1,4 +0,0 @@
import { container } from 'src/inversify.config';
import { ITipiCache } from './TipiCache';
export const tipiCache = container.get<ITipiCache>('ITipiCache');

View File

@ -1,15 +0,0 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { TipiConfig } from '../core/TipiConfig/TipiConfig';
import * as schema from './schema';
const connectionString = `postgresql://${TipiConfig.getConfig().postgresUsername}:${TipiConfig.getConfig().postgresPassword}@${
TipiConfig.getConfig().postgresHost
}:${TipiConfig.getConfig().postgresPort}/${TipiConfig.getConfig().postgresDatabase}?connect_timeout=300`;
const pool = new Pool({
connectionString,
});
export const db = drizzle(pool, { schema });
export type Database = typeof db;

View File

@ -1,13 +1,23 @@
import { and, asc, eq, ne, notInArray } from 'drizzle-orm';
import { Database, db } from '@/server/db';
import { appTable, NewApp, AppStatus } from '../../db/schema';
import { appTable, App, NewApp, AppStatus, IDbClient } from '@runtipi/db';
import { inject, injectable } from 'inversify';
export class AppQueries {
private db;
export interface IAppQueries {
getApp(appId: string): Promise<App | undefined>;
updateApp(appId: string, data: Partial<NewApp>): Promise<App | undefined>;
deleteApp(appId: string): Promise<void>;
createApp(data: NewApp): Promise<App | undefined>;
getAppsByStatus(status: AppStatus): Promise<App[]>;
getApps(): Promise<App[]>;
getGuestDashboardApps(): Promise<App[]>;
getAppsByDomain(domain: string, id: string): Promise<App[]>;
updateAppsByStatusNotIn(statuses: AppStatus[], data: Partial<NewApp>): Promise<App[]>;
}
constructor(p: Database = db) {
this.db = p;
}
@injectable()
export class AppQueries implements IAppQueries {
constructor(@inject('IDbClient') private dbClient: IDbClient) { }
/**
* Given an app id, return the app
@ -15,7 +25,7 @@ export class AppQueries {
* @param {string} appId - The id of the app to return
*/
public async getApp(appId: string) {
return this.db.query.appTable.findFirst({ where: eq(appTable.id, appId) });
return this.dbClient.db.query.appTable.findFirst({ where: eq(appTable.id, appId) });
}
/**
@ -25,7 +35,7 @@ export class AppQueries {
* @param {Partial<NewApp>} data - The data to update the app with
*/
public async updateApp(appId: string, data: Partial<NewApp>) {
const updatedApps = await this.db.update(appTable).set(data).where(eq(appTable.id, appId)).returning().execute();
const updatedApps = await this.dbClient.db.update(appTable).set(data).where(eq(appTable.id, appId)).returning().execute();
return updatedApps[0];
}
@ -35,7 +45,7 @@ export class AppQueries {
* @param {string} appId - The id of the app to delete
*/
public async deleteApp(appId: string) {
await this.db.delete(appTable).where(eq(appTable.id, appId)).execute();
await this.dbClient.db.delete(appTable).where(eq(appTable.id, appId)).execute();
}
/**
@ -44,7 +54,7 @@ export class AppQueries {
* @param {NewApp} data - The data to create the app with
*/
public async createApp(data: NewApp) {
const newApps = await this.db.insert(appTable).values(data).returning().execute();
const newApps = await this.dbClient.db.insert(appTable).values(data).returning().execute();
return newApps[0];
}
@ -54,21 +64,21 @@ export class AppQueries {
* @param {AppStatus} status - The status of the apps to return
*/
public async getAppsByStatus(status: AppStatus) {
return this.db.query.appTable.findMany({ where: eq(appTable.status, status), orderBy: asc(appTable.id) });
return this.dbClient.db.query.appTable.findMany({ where: eq(appTable.status, status), orderBy: asc(appTable.id) });
}
/**
* Returns all apps installed sorted by id ascending
*/
public async getApps() {
return this.db.query.appTable.findMany({ orderBy: asc(appTable.id) });
return this.dbClient.db.query.appTable.findMany({ orderBy: asc(appTable.id) });
}
/**
* Returns all apps that are running and visible on guest dashboard sorted by id ascending
*/
public async getGuestDashboardApps() {
return this.db.query.appTable.findMany({
return this.dbClient.db.query.appTable.findMany({
where: and(eq(appTable.status, 'running'), eq(appTable.isVisibleOnGuestDashboard, true)),
orderBy: asc(appTable.id),
});
@ -81,7 +91,7 @@ export class AppQueries {
* @param {string} id - The id of the app to exclude
*/
public async getAppsByDomain(domain: string, id: string) {
return this.db.query.appTable.findMany({ where: and(eq(appTable.domain, domain), eq(appTable.exposed, true), ne(appTable.id, id)) });
return this.dbClient.db.query.appTable.findMany({ where: and(eq(appTable.domain, domain), eq(appTable.exposed, true), ne(appTable.id, id)) });
}
/**
@ -91,6 +101,6 @@ export class AppQueries {
* @param {Partial<NewApp>} data - The data to update the apps with
*/
public async updateAppsByStatusNotIn(statuses: AppStatus[], data: Partial<NewApp>) {
return this.db.update(appTable).set(data).where(notInArray(appTable.status, statuses)).returning().execute();
return this.dbClient.db.update(appTable).set(data).where(notInArray(appTable.status, statuses)).returning().execute();
}
}

View File

@ -1,13 +1,20 @@
import { eq } from 'drizzle-orm';
import { Database } from '@/server/db';
import { userTable, NewUser } from '../../db/schema';
import { inject, injectable } from 'inversify';
import { type IDbClient, type NewUser, type User, userTable } from '@runtipi/db';
export class AuthQueries {
private db;
export interface IAuthQueries {
getUserByUsername(username: string): Promise<User | undefined>;
getUserById(id: number): Promise<User | undefined>;
getUserDtoById(id: number): Promise<Pick<User, 'id' | 'username' | 'totpEnabled' | 'locale' | 'operator'> | undefined>;
updateUser(id: number, data: Partial<NewUser>): Promise<User | undefined>;
getOperators(): Promise<User[]>;
getFirstOperator(): Promise<User | undefined>;
createUser(data: NewUser): Promise<User | undefined>;
}
constructor(p: Database) {
this.db = p;
}
@injectable()
export class AuthQueries implements IAuthQueries {
constructor(@inject('IDbClient') private dbClient: IDbClient) {}
/**
* Given a username, return the user associated to it
@ -15,7 +22,7 @@ export class AuthQueries {
* @param {string} username - The username of the user to return
*/
public async getUserByUsername(username: string) {
return this.db.query.userTable.findFirst({ where: eq(userTable.username, username.trim().toLowerCase()) });
return this.dbClient.db.query.userTable.findFirst({ where: eq(userTable.username, username.trim().toLowerCase()) });
}
/**
@ -24,7 +31,7 @@ export class AuthQueries {
* @param {number} id - The id of the user to return
*/
public async getUserById(id: number) {
return this.db.query.userTable.findFirst({ where: eq(userTable.id, Number(id)) });
return this.dbClient.db.query.userTable.findFirst({ where: eq(userTable.id, Number(id)) });
}
/**
@ -33,7 +40,10 @@ export class AuthQueries {
* @param {number} id - The id of the user to return
*/
public async getUserDtoById(id: number) {
return this.db.query.userTable.findFirst({ where: eq(userTable.id, Number(id)), columns: { id: true, username: true, totpEnabled: true, locale: true, operator: true } });
return this.dbClient.db.query.userTable.findFirst({
where: eq(userTable.id, Number(id)),
columns: { id: true, username: true, totpEnabled: true, locale: true, operator: true },
});
}
/**
@ -43,7 +53,7 @@ export class AuthQueries {
* @param {Partial<NewUser>} data - The data to update the user with
*/
public async updateUser(id: number, data: Partial<NewUser>) {
const updatedUsers = await this.db
const updatedUsers = await this.dbClient.db
.update(userTable)
.set(data)
.where(eq(userTable.id, Number(id)))
@ -56,14 +66,14 @@ export class AuthQueries {
* Returns all operators registered in the system
*/
public async getOperators() {
return this.db.select().from(userTable).where(eq(userTable.operator, true));
return this.dbClient.db.select().from(userTable).where(eq(userTable.operator, true));
}
/**
* Returns the first operator found in the system
*/
public async getFirstOperator() {
return this.db.query.userTable.findFirst({ where: eq(userTable.operator, true) });
return this.dbClient.db.query.userTable.findFirst({ where: eq(userTable.operator, true) });
}
/**
@ -72,7 +82,7 @@ export class AuthQueries {
* @param {NewUser} data - The data to create the user with
*/
public async createUser(data: NewUser) {
const newUsers = await this.db.insert(userTable).values(data).returning();
const newUsers = await this.dbClient.db.insert(userTable).values(data).returning();
return newUsers[0];
}
}

View File

@ -1,14 +1,18 @@
import { Database } from '@/server/db';
import { linkTable } from '@/server/db/schema';
import { LinkInfoInput } from '@runtipi/shared';
import { type IDbClient, type Link, linkTable } from '@runtipi/db';
import type { LinkInfoInput } from '@runtipi/shared';
import { eq, and } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
export class LinkQueries {
private db;
export interface ILinkQueries {
addLink(link: LinkInfoInput, userId: number): Promise<Link | undefined>;
editLink(link: LinkInfoInput, userId: number): Promise<Link | undefined>;
deleteLink(linkId: number, userId: number): Promise<void>;
getLinks(userId: number): Promise<Link[]>;
}
constructor(p: Database) {
this.db = p;
}
@injectable()
export class LinkQueries implements ILinkQueries {
constructor(@inject('IDbClient') private dbClient: IDbClient) {}
/**
* Adds a new link to the database.
@ -17,7 +21,7 @@ export class LinkQueries {
*/
public async addLink(link: LinkInfoInput, userId: number) {
const { title, description, url, iconUrl } = link;
const newLinks = await this.db.insert(linkTable).values({ title, description, url, iconUrl, userId }).returning().execute();
const newLinks = await this.dbClient.db.insert(linkTable).values({ title, description, url, iconUrl, userId }).returning();
return newLinks[0];
}
@ -32,12 +36,11 @@ export class LinkQueries {
if (!id) throw new Error('No id provided');
const updatedLinks = await this.db
const updatedLinks = await this.dbClient.db
.update(linkTable)
.set({ title, description, url, iconUrl, updatedAt: new Date() })
.where(and(eq(linkTable.id, id), eq(linkTable.userId, userId)))
.returning()
.execute();
.returning();
return updatedLinks[0];
}
@ -47,11 +50,7 @@ export class LinkQueries {
* @param {number} linkId - The id of the link to be deleted.
*/
public async deleteLink(linkId: number, userId: number) {
await this.db
.delete(linkTable)
.where(and(eq(linkTable.id, linkId), eq(linkTable.userId, userId)))
.returning()
.execute();
await this.dbClient.db.delete(linkTable).where(and(eq(linkTable.id, linkId), eq(linkTable.userId, userId)));
}
/**
@ -60,7 +59,6 @@ export class LinkQueries {
* @returns An array of links belonging to the user.
*/
public async getLinks(userId: number) {
const links = await this.db.select().from(linkTable).where(eq(linkTable.userId, userId)).orderBy(linkTable.id);
return links;
return this.dbClient.db.select().from(linkTable).where(eq(linkTable.userId, userId)).orderBy(linkTable.id);
}
}

View File

@ -1,4 +1,4 @@
import path from 'path';
import path from 'node:path';
import pg from 'pg';
import { migrate } from '@runtipi/postgres-migrations';
import { createClient } from 'redis';
@ -36,11 +36,11 @@ export const runPostgresMigrations = async (dbName?: string) => {
Logger.info('Running migrations');
try {
await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true });
await migrate({ client }, path.join(__dirname, '../../packages/db/assets/migrations'), { skipCreateMigrationTable: true });
} catch (e) {
Logger.error('Error running migrations. Dropping table migrations and trying again');
await client.query('DROP TABLE migrations');
await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true });
await migrate({ client }, path.join(__dirname, '../../packages/db/assets/migrations'), { skipCreateMigrationTable: true });
}
Logger.info('Migration complete');

View File

@ -1,10 +1,11 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { IAppBackupCommand } from './commands/types';
import type { IAppBackupCommand } from './commands/types';
import { AppDataService } from '@runtipi/shared/node';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { TipiConfig } from '@/server/core/TipiConfig';
import { CreateAppBackupCommand, DeleteAppBackupCommand, GetAppBackupsCommand, RestoreAppBackupCommand } from './commands';
import { container } from 'src/inversify.config';
export const availableCommands = {
createAppBackup: CreateAppBackupCommand,
@ -28,7 +29,7 @@ export class AppBackupClass {
private commandInvoker: CommandInvoker;
constructor(
private queries: AppQueries,
private queries: IAppQueries,
private eventDispatcher: EventDispatcher,
private appDataService: AppDataService,
) {
@ -57,7 +58,7 @@ export class AppBackupClass {
export type AppBackup = InstanceType<typeof AppBackupClass>;
const queries = new AppQueries();
const queries = container.get<IAppQueries>('IAppQueries');
const eventDispatcher = new EventDispatcher();
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });

View File

@ -1,13 +1,13 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { AppBackupCommandParams, IAppBackupCommand } from './types';
import { EventDispatcher } from '@/server/core/EventDispatcher';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import type { AppBackupCommandParams, IAppBackupCommand } from './types';
import type { EventDispatcher } from '@/server/core/EventDispatcher';
import { Logger } from '@/server/core/Logger';
import { TranslatedError } from '@/server/utils/errors';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { appLifecycle } from '../../app-lifecycle/app-lifecycle.service';
export class CreateAppBackupCommand implements IAppBackupCommand {
private queries: AppQueries;
private queries: IAppQueries;
private eventDispatcher: EventDispatcher;
constructor(params: AppBackupCommandParams) {
@ -15,7 +15,7 @@ export class CreateAppBackupCommand implements IAppBackupCommand {
this.eventDispatcher = params.eventDispatcher;
}
private async sendEvent(appId: string, appStatusBeforeUpdate: AppStatus): Promise<void> {
private async sendEvent(appId: string, appStatusBeforeUpdate?: AppStatus): Promise<void> {
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync(
{ type: 'app', command: 'backup', appid: appId, form: {} },
1000 * 60 * 15, // 15 minutes

View File

@ -1,13 +1,13 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { AppBackupCommandParams, IAppBackupCommand } from './types';
import { EventDispatcher } from '@/server/core/EventDispatcher';
import type { AppBackupCommandParams, IAppBackupCommand } from './types';
import type { EventDispatcher } from '@/server/core/EventDispatcher';
import { Logger } from '@/server/core/Logger';
import { TranslatedError } from '@/server/utils/errors';
import { AppStatus } from '@/server/db/schema';
import type { AppStatus } from '@runtipi/db';
import { appLifecycle } from '../../app-lifecycle/app-lifecycle.service';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
export class RestoreAppBackupCommand implements IAppBackupCommand {
private queries: AppQueries;
private queries: IAppQueries;
private eventDispatcher: EventDispatcher;
constructor(params: AppBackupCommandParams) {

View File

@ -1,13 +1,13 @@
import { EventDispatcher } from '@/server/core/EventDispatcher';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { AppDataService } from '@runtipi/shared/node';
import type { EventDispatcher } from '@/server/core/EventDispatcher';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import type { AppDataService } from '@runtipi/shared/node';
export interface IAppBackupCommand<T = unknown> {
execute(...args: unknown[]): Promise<T>;
}
export type AppBackupCommandParams = {
queries: AppQueries;
queries: IAppQueries;
eventDispatcher: EventDispatcher;
appDataService: AppDataService;
executeOtherCommand: IAppBackupCommand['execute'];

View File

@ -1,7 +1,7 @@
import fs from 'fs-extra';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { type TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
import path from 'path';
import path from 'node:path';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { beforeEach, beforeAll, afterAll, describe, it, expect } from 'vitest';
import { AppCatalogClass } from './app-catalog.service';
@ -21,7 +21,7 @@ beforeAll(async () => {
beforeEach(async () => {
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
appCatalog = new AppCatalogClass(new AppQueries(db.db), new AppCatalogCache(appDataService), appDataService);
appCatalog = new AppCatalogClass(new AppQueries(db.dbClient), new AppCatalogCache(appDataService), appDataService);
await clearDatabase(db);
await TipiConfig.setConfig('version', 'test');
});

View File

@ -1,4 +1,4 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import { GetInstalledAppsCommand, GetGuestDashboardApps, GetAppCommand } from './commands';
import { AppDataService } from '@runtipi/shared/node';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
@ -6,7 +6,8 @@ import { TipiConfig } from '@/server/core/TipiConfig';
import { SearchAppsCommand } from './commands/search-apps-command';
import { ListAppsCommand } from './commands/list-apps-command';
import { AppCatalogCache } from './app-catalog-cache';
import { IAppCatalogCommand } from './commands/types';
import type { IAppCatalogCommand } from './commands/types';
import { container } from 'src/inversify.config';
const availableCommands = {
getInstalledApps: GetInstalledAppsCommand,
@ -31,7 +32,7 @@ export class AppCatalogClass {
private commandInvoker: CommandInvoker;
constructor(
private queries: AppQueries,
private queries: IAppQueries,
private appCatalogCache: AppCatalogCache,
private appDataService: AppDataService,
) {
@ -59,7 +60,7 @@ export class AppCatalogClass {
export type AppCatalog = InstanceType<typeof AppCatalogClass>;
const queries = new AppQueries();
const queries = container.get<IAppQueries>('IAppQueries');
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });
const appCacheManager = new AppCatalogCache(appDataService);

View File

@ -1,13 +1,13 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { type App } from '@/server/db/schema';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import type { App } from '@runtipi/db';
import { TranslatedError } from '@/server/utils/errors';
import { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import { AppDataService } from '@runtipi/shared/node';
import type { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import type { AppDataService } from '@runtipi/shared/node';
type ReturnValue = Awaited<ReturnType<InstanceType<typeof GetAppCommand>['execute']>>;
export class GetAppCommand implements IAppCatalogCommand<ReturnValue> {
private queries: AppQueries;
private queries: IAppQueries;
private appDataService: AppDataService;
constructor(params: AppCatalogCommandParams) {

View File

@ -1,12 +1,12 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import { notEmpty } from '@/server/common/typescript.helpers';
import { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import { AppDataService } from '@runtipi/shared/node';
import type { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import type { AppDataService } from '@runtipi/shared/node';
type ReturnValue = Awaited<ReturnType<InstanceType<typeof GetGuestDashboardApps>['execute']>>;
export class GetGuestDashboardApps implements IAppCatalogCommand<ReturnValue> {
private queries: AppQueries;
private queries: IAppQueries;
private appDataService: AppDataService;
constructor(params: AppCatalogCommandParams) {

View File

@ -1,12 +1,12 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import { notEmpty } from '@/server/common/typescript.helpers';
import { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import { AppDataService } from '@runtipi/shared/node';
import type { AppCatalogCommandParams, IAppCatalogCommand } from './types';
import type { AppDataService } from '@runtipi/shared/node';
type ReturnValue = Awaited<ReturnType<InstanceType<typeof GetInstalledAppsCommand>['execute']>>;
export class GetInstalledAppsCommand implements IAppCatalogCommand<ReturnValue> {
private queries: AppQueries;
private queries: IAppQueries;
private appDataService: AppDataService;
constructor(params: AppCatalogCommandParams) {

View File

@ -1,13 +1,13 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { AppDataService } from '@runtipi/shared/node';
import { AppCatalogCache } from '../app-catalog-cache';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import type { AppDataService } from '@runtipi/shared/node';
import type { AppCatalogCache } from '../app-catalog-cache';
export interface IAppCatalogCommand<T = unknown> {
execute(...args: unknown[]): Promise<T>;
}
export type AppCatalogCommandParams = {
queries: AppQueries;
queries: IAppQueries;
appDataService: AppDataService;
appCatalogCache: AppCatalogCache;
};

View File

@ -1,4 +1,4 @@
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { type TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { vi, beforeEach, beforeAll, afterAll, describe, it, expect } from 'vitest';
import { AppLifecycleClass } from './app-lifecycle.service';
import { EventDispatcher } from '../../core/EventDispatcher';
@ -17,7 +17,7 @@ const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_D
beforeAll(async () => {
db = await createDatabase(TEST_SUITE);
appLifecycle = new AppLifecycleClass(new AppQueries(db.db), dispatcher, appDataService);
appLifecycle = new AppLifecycleClass(new AppQueries(db.dbClient), dispatcher, appDataService);
});
beforeEach(async () => {

View File

@ -1,6 +1,6 @@
import { AppQueries } from '@/server/queries/apps/apps.queries';
import type { IAppQueries } from '@/server/queries/apps/apps.queries';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { IAppLifecycleCommand } from './commands/types';
import type { IAppLifecycleCommand } from './commands/types';
import { AppDataService } from '@runtipi/shared/node';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { TipiConfig } from '@/server/core/TipiConfig';
@ -14,6 +14,7 @@ import {
UpdateAppCommand,
UpdateAppConfigCommand,
} from './commands';
import { container } from 'src/inversify.config';
export const availableCommands = {
startApp: StartAppCommand,
@ -41,7 +42,7 @@ export class AppLifecycleClass {
private commandInvoker: CommandInvoker;
constructor(
private queries: AppQueries,
private queries: IAppQueries,
private eventDispatcher: EventDispatcher,
private appDataService: AppDataService,
) {
@ -70,7 +71,7 @@ export class AppLifecycleClass {
export type AppLifecycle = InstanceType<typeof AppLifecycleClass>;
const queries = new AppQueries();
const queries = container.get<IAppQueries>('IAppQueries');
const eventDispatcher = new EventDispatcher();
const appDataService = new AppDataService({ dataDir: DATA_DIR, appDataDir: APP_DATA_DIR, appsRepoId: TipiConfig.getConfig().appsRepoId });

Some files were not shown because too many files have changed in this diff Show More