From b4be96c7a8fbf67243d29e83ad087e3d541ac8bc Mon Sep 17 00:00:00 2001 From: diced Date: Thu, 16 Oct 2025 21:02:17 -0700 Subject: [PATCH] feat: support separate db vars + file version --- src/lib/config/read/env.ts | 67 ++++++++++++++++++++++++++++++++---- src/lib/config/read/index.ts | 7 ++++ src/lib/config/validate.ts | 24 ++++++++++++- src/lib/db/index.ts | 20 +++++++++-- src/server/index.ts | 5 +-- 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/lib/config/read/env.ts b/src/lib/config/read/env.ts index 6360bc46..b861bdc4 100644 --- a/src/lib/config/read/env.ts +++ b/src/lib/config/read/env.ts @@ -1,9 +1,9 @@ import { log } from '@/lib/logger'; -import { parse } from './transform'; import { readFileSync } from 'node:fs'; +import { parse } from './transform'; export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json'; -export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) { +export function env(property: string, env: string, type: EnvType, isDb: boolean = false) { return { variable: env, property, @@ -16,7 +16,14 @@ export const ENVS = [ env('core.port', 'CORE_PORT', 'number'), env('core.hostname', 'CORE_HOSTNAME', 'string'), env('core.secret', 'CORE_SECRET', 'string'), - env('core.databaseUrl', ['DATABASE_URL', 'CORE_DATABASE_URL'], 'string'), + + env('core.databaseUrl', 'DATABASE_URL', 'string'), + // or + env('core.database.username', 'DATABASE_USERNAME', 'string', true), + env('core.database.password', 'DATABASE_PASSWORD', 'string', true), + env('core.database.host', 'DATABASE_HOST', 'string', true), + env('core.database.port', 'DATABASE_PORT', 'number', true), + env('core.database.name', 'DATABASE_NAME', 'string', true), env('datasource.type', 'DATASOURCE_TYPE', 'string'), env('datasource.s3.accessKeyId', 'DATASOURCE_S3_ACCESS_KEY_ID', 'string'), @@ -161,11 +168,62 @@ export const PROP_TO_ENV: Record = Object.fromEntries ENVS.map((env) => [env.property, env.variable]), ); +export const REQUIRED_DB_VARS = [ + 'DATABASE_USERNAME', + 'DATABASE_PASSWORD', + 'DATABASE_HOST', + 'DATABASE_PORT', + 'DATABASE_NAME', +]; + type EnvResult = { env: Record; dbEnv: Record; }; +export function checkDbVars(): boolean { + if (process.env.DATABASE_URL) return true; + + for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) { + if (process.env[REQUIRED_DB_VARS[i]] === undefined) { + return false; + } + } + + return true; +} + +export function readDbVars(): Record { + const logger = log('config').c('readDbVars'); + + if (process.env.DATABASE_URL) return { DATABASE_URL: process.env.DATABASE_URL }; + + const dbVars: Record = {}; + for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) { + const value = process.env[REQUIRED_DB_VARS[i]]; + const valueFileName = process.env[`${REQUIRED_DB_VARS[i]}_FILE`]; + if (valueFileName) { + try { + dbVars[REQUIRED_DB_VARS[i]] = readFileSync(valueFileName, 'utf-8').trim(); + } catch { + logger.error(`Failed to read database env value from file for ${REQUIRED_DB_VARS[i]}. Exiting...`); + process.exit(1); + } + } else if (value) { + dbVars[REQUIRED_DB_VARS[i]] = value; + } + } + + if (!Object.keys(dbVars).length || Object.keys(dbVars).length !== REQUIRED_DB_VARS.length) { + logger.error( + `No database environment variables found (DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}]), exiting...`, + ); + process.exit(1); + } + + return dbVars; +} + export function readEnv(): EnvResult { const logger = log('config').c('readEnv'); const envResult: EnvResult = { @@ -175,9 +233,6 @@ export function readEnv(): EnvResult { for (let i = 0; i !== ENVS.length; ++i) { const env = ENVS[i]; - if (Array.isArray(env.variable)) { - env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL'; - } let value = process.env[env.variable]; const valueFileName = process.env[`${env.variable}_FILE`]; diff --git a/src/lib/config/read/index.ts b/src/lib/config/read/index.ts index 62c9a79b..11367665 100755 --- a/src/lib/config/read/index.ts +++ b/src/lib/config/read/index.ts @@ -14,6 +14,13 @@ export const rawConfig: any = { returnHttpsUrls: undefined, tempDirectory: undefined, trustProxy: undefined, + database: { + username: undefined, + password: undefined, + host: undefined, + port: undefined, + name: undefined, + }, }, chunks: { max: undefined, diff --git a/src/lib/config/validate.ts b/src/lib/config/validate.ts index e299dedd..3aa2d250 100755 --- a/src/lib/config/validate.ts +++ b/src/lib/config/validate.ts @@ -67,7 +67,6 @@ export const schema = z.object({ }); } }), - databaseUrl: z.url(), returnHttpsUrls: z.boolean().default(false), defaultDomain: z.string().nullable().default(null), tempDirectory: z @@ -75,6 +74,29 @@ export const schema = z.object({ .transform((s) => resolve(s)) .default(join(tmpdir(), 'zipline')), trustProxy: z.boolean().default(false), + + databaseUrl: z.url(), + + database: z + .object({ + username: z.string().nullable().default(null), + password: z.string().nullable().default(null), + host: z.string().nullable().default(null), + port: z.number().nullable().default(null), + name: z.string().nullable().default(null), + }) + .superRefine((val, c) => { + const values = Object.values(val); + const someSet = values.some((v) => v !== null); + const allSet = values.every((v) => v !== null); + + if (someSet && !allSet) { + c.addIssue({ + code: 'custom', + message: 'If one database field is set, all fields must be set', + }); + } + }), }), chunks: z.object({ max: z.string().default('95mb'), diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index fc217736..6bd6a69b 100755 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -4,6 +4,7 @@ import { type Prisma, PrismaClient } from '@/prisma/client'; import { metadataSchema } from './models/incompleteFile'; import { metricDataSchema } from './models/metric'; import { userViewSchema } from './models/user'; +import { readDbVars, REQUIRED_DB_VARS } from '../config/read/env'; const building = !!process.env.ZIPLINE_BUILD; @@ -31,12 +32,27 @@ function parseDbLog(env: string): Prisma.LogLevel[] { .filter((v) => v) as unknown as Prisma.LogLevel[]; } +function pgConnectionString() { + const vars = readDbVars(); + if (vars.DATABASE_URL) return vars.DATABASE_URL; + + return `postgresql://${vars.DATABASE_USERNAME}:${vars.DATABASE_PASSWORD}@${vars.DATABASE_HOST}:${vars.DATABASE_PORT}/${vars.DATABASE_NAME}`; +} + function getClient() { const logger = log('db'); - logger.info('connecting to database ' + process.env.DATABASE_URL); + const connectionString = pgConnectionString(); + if (!connectionString) { + logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`); + process.exit(1); + } - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); + process.env.DATABASE_URL = connectionString; + + logger.info('connecting to database', { url: connectionString }); + + const adapter = new PrismaPg({ connectionString }); const client = new PrismaClient({ adapter, log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined, diff --git a/src/server/index.ts b/src/server/index.ts index 57500094..c511a86c 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import { bytes } from '@/lib/bytes'; import { reloadSettings } from '@/lib/config'; +import { checkDbVars, REQUIRED_DB_VARS } from '@/lib/config/read/env'; import { getDatasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { runMigrations } from '@/lib/db/migration'; @@ -46,8 +47,8 @@ async function main() { const argv = process.argv.slice(2); logger.info('starting zipline', { mode: MODE, version: version, argv }); - if (!process.env.DATABASE_URL) { - logger.error('DATABASE_URL not set, exiting...'); + if (!checkDbVars()) { + logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`); process.exit(1); }