cli-module-build: forward user config to embedded Postgres

When using the embedded-postgres database client, user-provided
connection config (host, port, user, password) is now forwarded to
the embedded Postgres instance. Only values that the user hasn't
configured are filled in with defaults and injected into the app
config, preserving existing behavior when no config is provided.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-04-30 11:34:51 +02:00
parent 6eda127a8c
commit be7e4eb48b
4 changed files with 183 additions and 18 deletions
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-build': patch
---
The embedded Postgres database used during local development now respects user-provided connection configuration. If you configure `host`, `port`, `user`, or `password` under `backend.database.connection` alongside the `embedded-postgres` database client, those values will be forwarded to the embedded Postgres instance. Only values that you have not configured will be filled in with defaults. This makes it possible to run the embedded database on a specific host and port, for example to connect to it externally with `psql`.
@@ -88,6 +88,7 @@ describe('runBackend', () => {
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: () => undefined,
getOptionalNumber: () => undefined,
});
mockStartEmbeddedDb.mockReset();
});
@@ -180,11 +181,12 @@ describe('runBackend', () => {
});
describe('embedded-postgres support', () => {
it('should start embedded DB and inject config when database client is embedded-postgres', async () => {
it('should start embedded DB and inject all defaults when no user connection config is provided', async () => {
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'embedded-postgres' : undefined,
getOptionalNumber: () => undefined,
});
mockStartEmbeddedDb.mockResolvedValue({
connection: {
@@ -193,14 +195,19 @@ describe('runBackend', () => {
password: 'password',
port: 5555,
},
defaultedConnection: {
host: 'localhost',
user: 'postgres',
password: 'password',
port: 5555,
},
close: jest.fn(),
});
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalled();
expect(mockStartEmbeddedDb).toHaveBeenCalledWith(undefined);
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
@@ -217,10 +224,112 @@ describe('runBackend', () => {
});
});
it('should forward user-provided connection config and only inject defaults for missing values', async () => {
const configValues: Record<string, string | number> = {
'backend.database.client': 'embedded-postgres',
'backend.database.connection.host': '0.0.0.0',
'backend.database.connection.port': 15432,
};
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: (key: string) => {
const val = configValues[key];
return typeof val === 'string' ? val : undefined;
},
getOptionalNumber: (key: string) => {
const val = configValues[key];
return typeof val === 'number' ? val : undefined;
},
});
mockStartEmbeddedDb.mockResolvedValue({
connection: {
host: '0.0.0.0',
user: 'postgres',
password: 'password',
port: 15432,
},
defaultedConnection: {
user: 'postgres',
password: 'password',
},
close: jest.fn(),
});
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
host: '0.0.0.0',
port: 15432,
user: undefined,
password: undefined,
});
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
>;
const injected = JSON.parse(spawnEnv.APP_CONFIG_backend_database);
expect(injected).toEqual({
client: 'pg',
connection: {
user: 'postgres',
password: 'password',
},
});
});
it('should not inject connection overrides when all values are user-provided', async () => {
const configValues: Record<string, string | number> = {
'backend.database.client': 'embedded-postgres',
'backend.database.connection.host': '0.0.0.0',
'backend.database.connection.port': 15432,
'backend.database.connection.user': 'myuser',
'backend.database.connection.password': 'mypass',
};
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: (key: string) => {
const val = configValues[key];
return typeof val === 'string' ? val : undefined;
},
getOptionalNumber: (key: string) => {
const val = configValues[key];
return typeof val === 'number' ? val : undefined;
},
});
mockStartEmbeddedDb.mockResolvedValue({
connection: {
host: '0.0.0.0',
user: 'myuser',
password: 'mypass',
port: 15432,
},
defaultedConnection: {},
close: jest.fn(),
});
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
host: '0.0.0.0',
port: 15432,
user: 'myuser',
password: 'mypass',
});
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
>;
const injected = JSON.parse(spawnEnv.APP_CONFIG_backend_database);
expect(injected).toEqual({ client: 'pg' });
});
it('should resolve config paths relative to targetDir', async () => {
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: () => undefined,
getOptionalNumber: () => undefined,
});
runBackend({
@@ -242,13 +351,13 @@ describe('runBackend', () => {
close: jest.fn(),
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'better-sqlite3' : undefined,
getOptionalNumber: () => undefined,
});
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).not.toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalled();
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
@@ -68,16 +68,19 @@ export async function runBackend(options: RunBackendOptions) {
let embeddedDb: Awaited<ReturnType<typeof startEmbeddedDb>> | undefined;
const dbClient = await readDatabaseClient(
const dbConfig = await readDatabaseConfig(
options.configPaths,
options.targetDir,
);
if (dbClient === 'embedded-postgres') {
embeddedDb = await startEmbeddedDb();
extraEnv.APP_CONFIG_backend_database = JSON.stringify({
if (dbConfig?.client === 'embedded-postgres') {
embeddedDb = await startEmbeddedDb(dbConfig.connection);
const overrides: Record<string, unknown> = {
client: 'pg',
connection: embeddedDb.connection,
});
};
if (Object.keys(embeddedDb.defaultedConnection).length > 0) {
overrides.connection = embeddedDb.defaultedConnection;
}
extraEnv.APP_CONFIG_backend_database = JSON.stringify(overrides);
}
let exiting = false;
@@ -221,10 +224,16 @@ export async function runBackend(options: RunBackendOptions) {
return () => exitPromise;
}
async function readDatabaseClient(
async function readDatabaseConfig(
configPaths?: string[],
targetDir?: string,
): Promise<string | undefined> {
): Promise<
| {
client: string;
connection?: import('./startEmbeddedDb').EmbeddedDbConnectionConfig;
}
| undefined
> {
const rootDir = targetPaths.rootDir;
const configBaseDir = targetDir ?? rootDir;
const source = ConfigSources.default({
@@ -238,7 +247,27 @@ async function readDatabaseClient(
const config = await ConfigSources.toConfig(source);
try {
return config.getOptionalString('backend.database.client');
const client = config.getOptionalString('backend.database.client');
if (!client) {
return undefined;
}
const host = config.getOptionalString('backend.database.connection.host');
const port = config.getOptionalNumber('backend.database.connection.port');
const user = config.getOptionalString('backend.database.connection.user');
const password = config.getOptionalString(
'backend.database.connection.password',
);
const connection =
host !== undefined ||
port !== undefined ||
user !== undefined ||
password !== undefined
? { host, port, user, password }
: undefined;
return { client, connection };
} finally {
config.close();
}
@@ -52,7 +52,14 @@ async function cleanStaleDatabases() {
);
}
export async function startEmbeddedDb() {
export interface EmbeddedDbConnectionConfig {
host?: string;
port?: number;
user?: string;
password?: string;
}
export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
console.warn(
chalk.yellow(
'WARNING: Using embedded-postgres for local development is experimental and subject to change',
@@ -72,10 +79,10 @@ export async function startEmbeddedDb() {
await cleanStaleDatabases();
const host = 'localhost';
const user = 'postgres';
const password = 'password';
const port = await getPortPromise();
const host = userConfig?.host ?? 'localhost';
const user = userConfig?.user ?? 'postgres';
const password = userConfig?.password ?? 'password';
const port = userConfig?.port ?? (await getPortPromise());
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), TEMP_DIR_PREFIX));
const pg = new EmbeddedPostgres({
@@ -100,6 +107,20 @@ export async function startEmbeddedDb() {
throw error;
}
const defaultedConnection: Record<string, string | number> = {};
if (!userConfig?.host) {
defaultedConnection.host = host;
}
if (!userConfig?.user) {
defaultedConnection.user = user;
}
if (!userConfig?.password) {
defaultedConnection.password = password;
}
if (!userConfig?.port) {
defaultedConnection.port = port;
}
return {
connection: {
host,
@@ -107,6 +128,7 @@ export async function startEmbeddedDb() {
password,
port,
},
defaultedConnection,
async close() {
await pg.stop();
await fs.remove(tmpDir);