Add support for individual database environment variables (#9344)

* Add support for individual database environment variables

- Add DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD env vars
- Implement mutual exclusivity validation between DATABASE_URL and individual components
- Add effectiveDatabaseUrl getter to construct URL from individual components
- Update database connection logic to use new configuration options
- Ensure backward compatibility with existing DATABASE_URL configuration

Resolves: https://github.com/outline/outline/discussions/9158

* Refactor database configuration methods

- Move effectiveDatabaseUrl method from env.ts to database.ts as getEffectiveDatabaseUrl function
- Remove validateDatabaseConfiguration method from env.ts as validation is handled by decorators
- Maintain clean separation of concerns between environment and database modules

* Pass database options directly to Sequelize constructor

- Replace URL construction with direct Sequelize configuration object
- Support both DATABASE_URL string and individual component object configurations
- Maintain common Sequelize options for both configuration types
- Improve error messaging for different configuration scenarios

* remove spurious comments

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-05-31 11:30:28 -04:00
committed by GitHub
parent 7a5480f12f
commit cd0acc40bb
3 changed files with 128 additions and 10 deletions
+50 -1
View File
@@ -16,7 +16,11 @@ import {
import uniq from "lodash/uniq";
import { languages } from "@shared/i18n";
import { Day, Hour } from "@shared/utils/time";
import { CannotUseWith, CannotUseWithout } from "@server/utils/validators";
import {
CannotUseWith,
CannotUseWithout,
CannotUseWithAny,
} from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args";
import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public";
@@ -79,8 +83,53 @@ export class Environment {
allow_underscores: true,
protocols: ["postgres", "postgresql"],
})
@CannotUseWithAny([
"DATABASE_HOST",
"DATABASE_PORT",
"DATABASE_NAME",
"DATABASE_USER",
"DATABASE_PASSWORD",
])
public DATABASE_URL = environment.DATABASE_URL ?? "";
/**
* Database host for individual component configuration.
*/
@IsOptional()
@CannotUseWith("DATABASE_URL")
public DATABASE_HOST = this.toOptionalString(environment.DATABASE_HOST);
/**
* Database port for individual component configuration.
*/
@IsOptional()
@IsNumber()
@CannotUseWith("DATABASE_URL")
public DATABASE_PORT = this.toOptionalNumber(environment.DATABASE_PORT);
/**
* Database name for individual component configuration.
*/
@IsOptional()
@CannotUseWith("DATABASE_URL")
public DATABASE_NAME = this.toOptionalString(environment.DATABASE_NAME);
/**
* Database user for individual component configuration.
*/
@IsOptional()
@CannotUseWith("DATABASE_URL")
public DATABASE_USER = this.toOptionalString(environment.DATABASE_USER);
/**
* Database password for individual component configuration.
*/
@IsOptional()
@CannotUseWith("DATABASE_URL")
public DATABASE_PASSWORD = this.toOptionalString(
environment.DATABASE_PASSWORD
);
/**
* An optional database schema.
*/
+48 -9
View File
@@ -1,21 +1,47 @@
import path from "path";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import sequelizeStrictAttributes from "sequelize-strict-attributes";
import { Sequelize } from "sequelize-typescript";
import { Sequelize, SequelizeOptions } from "sequelize-typescript";
import { Umzug, SequelizeStorage, MigrationError } from "umzug";
import env from "@server/env";
import Model from "@server/models/base/Model";
import Logger from "../logging/Logger";
import * as models from "../models";
/**
* Returns database configuration for Sequelize constructor.
* Either uses DATABASE_URL or constructs options from individual components.
*/
function getDatabaseConfig() {
if (env.DATABASE_URL) {
return env.DATABASE_URL;
}
// If using individual components, return Sequelize options object
if (env.DATABASE_HOST && env.DATABASE_NAME && env.DATABASE_USER) {
return {
database: env.DATABASE_NAME,
username: env.DATABASE_USER,
password: env.DATABASE_PASSWORD || undefined,
host: env.DATABASE_HOST,
port: env.DATABASE_PORT || 5432,
dialect: "postgres" as const,
};
}
throw new Error(
"DATABASE_URL is not set or individual database components (DATABASE_HOST, DATABASE_NAME, DATABASE_USER) are not properly configured."
);
}
const isSSLDisabled = env.PGSSLMODE === "disable";
const poolMax = env.DATABASE_CONNECTION_POOL_MAX ?? 5;
const poolMin = env.DATABASE_CONNECTION_POOL_MIN ?? 0;
const url = env.DATABASE_CONNECTION_POOL_URL || env.DATABASE_URL;
const databaseConfig = env.DATABASE_CONNECTION_POOL_URL || getDatabaseConfig();
const schema = env.DATABASE_SCHEMA;
export function createDatabaseInstance(
databaseUrl: string,
databaseConfig: string | object,
input: {
[key: string]: typeof Model<
InferAttributes<Model>,
@@ -24,7 +50,10 @@ export function createDatabaseInstance(
}
): Sequelize {
try {
const instance = new Sequelize(databaseUrl, {
let instance;
// Common options for both URL and object configurations
const commonOptions: SequelizeOptions = {
logging: (msg) =>
process.env.DEBUG?.includes("database") &&
Logger.debug("database", msg),
@@ -47,17 +76,27 @@ export function createDatabaseInstance(
idle: 10000,
},
schema,
});
};
// If databaseConfig is a string, it's a URL; if it's an object, merge with common options
if (typeof databaseConfig === "string") {
instance = new Sequelize(databaseConfig, commonOptions);
} else {
instance = new Sequelize({ ...databaseConfig, ...commonOptions });
}
sequelizeStrictAttributes(instance);
return instance;
} catch (error) {
Logger.fatal(
"Could not connect to database",
databaseUrl
typeof databaseConfig === "string"
? new Error(
`Failed to parse: "${databaseUrl}". Ensure special characters in database URL are encoded`
`Failed to parse: "${databaseConfig}". Ensure special characters in database URL are encoded`
)
: new Error(
`Failed to connect using database credentials. Please check DATABASE_HOST, DATABASE_NAME, DATABASE_USER configuration`
)
: new Error(`DATABASE_URL is not set.`)
);
process.exit(1);
}
@@ -131,7 +170,7 @@ export function createMigrationRunner(
});
}
export const sequelize = createDatabaseInstance(url, models);
export const sequelize = createDatabaseInstance(databaseConfig, models);
export const migrations = createMigrationRunner(sequelize, [
"migrations/*.js",
+30
View File
@@ -57,3 +57,33 @@ export function CannotUseWith(
});
};
}
export function CannotUseWithAny(
properties: string[],
validationOptions?: ValidationOptions
) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: "cannotUseWithAny",
target: object.constructor,
propertyName,
constraints: properties,
options: validationOptions,
validator: {
validate<T>(value: T, args: ValidationArguments) {
if (value === undefined) {
return true;
}
const obj = args.object as unknown as T;
const forbiddenProperties = args.constraints as (keyof T)[];
return forbiddenProperties.every((prop) => obj[prop] === undefined);
},
defaultMessage(args: ValidationArguments) {
return `${propertyName} cannot be used with any of: ${args.constraints.join(
", "
)}.`;
},
},
});
};
}