Move config reading into startEmbeddedDb

Encapsulate the config-loading and embedded-postgres decision inside
`startEmbeddedDb` so that `runBackend` only needs to pass through the
raw `configPaths` and `targetDir`. This simplifies the interface between
the two modules and keeps all embedded-postgres concerns in one place.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-04-30 13:07:04 +02:00
parent a3a1802284
commit c9468995fa
3 changed files with 131 additions and 253 deletions
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { ConfigSources } from '@backstage/config-loader';
import { runBackend } from './runBackend';
import spawn from 'cross-spawn';
@@ -51,15 +49,6 @@ jest.mock('ctrlc-windows', () => ({
ctrlc: jest.fn(),
}));
const mockConfigSourcesDefault = jest.fn().mockReturnValue({});
jest.mock('@backstage/config-loader', () => ({
ConfigSources: {
default: (...args: any[]) => mockConfigSourcesDefault(...args),
toConfig: jest.fn(),
},
}));
const mockStartEmbeddedDb = jest.fn();
jest.mock('./startEmbeddedDb', () => ({
@@ -70,17 +59,6 @@ describe('runBackend', () => {
let originalEnv: NodeJS.ProcessEnv;
let originalPlatform: string;
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
const mockToConfig = ConfigSources.toConfig as jest.MockedFunction<
typeof ConfigSources.toConfig
>;
function mockConfig(
overrides?: Parameters<typeof mockServices.rootConfig.mock>[0],
) {
const config = mockServices.rootConfig.mock(overrides);
mockToConfig.mockResolvedValue(Object.assign(config, { close: jest.fn() }));
return config;
}
beforeEach(() => {
// Use fake timers to control debounce
@@ -97,8 +75,8 @@ describe('runBackend', () => {
// Mock process.once to prevent actual signal handling
jest.spyOn(process, 'once').mockReturnValue(process);
mockConfig();
mockStartEmbeddedDb.mockReset();
mockStartEmbeddedDb.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -189,23 +167,16 @@ describe('runBackend', () => {
});
describe('embedded-postgres support', () => {
it('should start embedded DB and inject all defaults when no user connection config is provided', async () => {
mockConfig({
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'embedded-postgres' : undefined,
});
it('should inject config override from startEmbeddedDb when it returns a result', async () => {
mockStartEmbeddedDb.mockResolvedValue({
connection: {
host: 'localhost',
user: 'postgres',
password: 'password',
port: 5555,
},
defaultedConnection: {
host: 'localhost',
user: 'postgres',
password: 'password',
port: 5555,
configOverride: {
client: 'pg',
connection: {
host: 'localhost',
user: 'postgres',
password: 'password',
port: 5555,
},
},
close: jest.fn(),
});
@@ -213,7 +184,10 @@ describe('runBackend', () => {
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).toHaveBeenCalledWith(undefined);
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
configPaths: undefined,
targetDir: undefined,
});
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
@@ -230,119 +204,7 @@ 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,
};
mockConfig({
getOptional: ((key: string) =>
key === 'backend.database.connection'
? { host: '0.0.0.0', port: 15432 }
: undefined) as any,
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',
};
mockConfig({
getOptional: ((key: string) =>
key === 'backend.database.connection'
? {
host: '0.0.0.0',
port: 15432,
user: 'myuser',
password: 'mypass',
}
: undefined) as any,
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 () => {
it('should forward configPaths and targetDir to startEmbeddedDb', async () => {
runBackend({
entry: 'src/index',
targetDir: '/root/packages/backend',
@@ -350,27 +212,18 @@ describe('runBackend', () => {
});
await jest.advanceTimersByTimeAsync(100);
expect(mockConfigSourcesDefault).toHaveBeenCalledWith(
expect.objectContaining({
argv: ['--config', '/root/config/local.yaml'],
}),
);
expect(mockStartEmbeddedDb).toHaveBeenCalledWith({
configPaths: ['../../config/local.yaml'],
targetDir: '/root/packages/backend',
});
});
it('should not start embedded DB for other database clients with a string connection', async () => {
mockConfig({
getOptional: ((key: string) =>
key === 'backend.database.connection'
? ':memory:'
: undefined) as any,
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'better-sqlite3' : undefined,
});
it('should not inject config override when startEmbeddedDb returns undefined', async () => {
mockStartEmbeddedDb.mockResolvedValue(undefined);
runBackend({ entry: 'src/index' });
await jest.advanceTimersByTimeAsync(100);
expect(mockStartEmbeddedDb).not.toHaveBeenCalled();
const spawnEnv = mockSpawn.mock.calls[0][2]?.env as Record<
string,
string
@@ -20,12 +20,8 @@ import { ctrlc } from 'ctrlc-windows';
import { IpcServer, ServerDataStore } from '../ipc';
import debounce from 'lodash/debounce';
import { fileURLToPath } from 'node:url';
import {
isAbsolute as isAbsolutePath,
resolve as resolvePath,
} from 'node:path';
import { isAbsolute as isAbsolutePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { ConfigSources } from '@backstage/config-loader';
import spawn from 'cross-spawn';
import { startEmbeddedDb } from './startEmbeddedDb';
@@ -66,21 +62,14 @@ export async function runBackend(options: RunBackendOptions) {
const extraEnv: Record<string, string> = {};
let embeddedDb: Awaited<ReturnType<typeof startEmbeddedDb>> | undefined;
const dbConfig = await readDatabaseConfig(
options.configPaths,
options.targetDir,
);
if (dbConfig?.client === 'embedded-postgres') {
embeddedDb = await startEmbeddedDb(dbConfig.connection);
const overrides: Record<string, unknown> = {
client: 'pg',
};
if (Object.keys(embeddedDb.defaultedConnection).length > 0) {
overrides.connection = embeddedDb.defaultedConnection;
}
extraEnv.APP_CONFIG_backend_database = JSON.stringify(overrides);
const embeddedDb = await startEmbeddedDb({
configPaths: options.configPaths,
targetDir: options.targetDir,
});
if (embeddedDb) {
extraEnv.APP_CONFIG_backend_database = JSON.stringify(
embeddedDb.configOverride,
);
}
let exiting = false;
@@ -223,59 +212,3 @@ export async function runBackend(options: RunBackendOptions) {
return () => exitPromise;
}
async function readDatabaseConfig(
configPaths?: string[],
targetDir?: string,
): Promise<
| {
client: string;
connection?: import('./startEmbeddedDb').EmbeddedDbConnectionConfig;
}
| undefined
> {
const rootDir = targetPaths.rootDir;
const configBaseDir = targetDir ?? rootDir;
const source = ConfigSources.default({
rootDir,
allowMissingDefaultConfig: true,
argv: (configPaths ?? []).flatMap(p => [
'--config',
isAbsolutePath(p) ? p : resolvePath(configBaseDir, p),
]),
});
const config = await ConfigSources.toConfig(source);
try {
const client = config.getOptionalString('backend.database.client');
if (!client) {
return undefined;
}
// Only read structured connection config if the value is an object;
// it can also be a plain string (e.g. ':memory:' for better-sqlite3).
const rawConnection = config.getOptional('backend.database.connection');
if (typeof rawConnection === 'string' || rawConnection === undefined) {
return { client };
}
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();
}
}
@@ -16,9 +16,14 @@
import os from 'node:os';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import {
isAbsolute as isAbsolutePath,
resolve as resolvePath,
} from 'node:path';
import { getPortPromise } from 'portfinder';
import { ForwardedError } from '@backstage/errors';
import { ConfigSources } from '@backstage/config-loader';
import { targetPaths } from '@backstage/cli-common';
import chalk from 'chalk';
const TEMP_DIR_PREFIX = 'backstage-dev-db-';
@@ -59,7 +64,38 @@ export interface EmbeddedDbConnectionConfig {
password?: string;
}
export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
export interface StartEmbeddedDbOptions {
configPaths?: string[];
targetDir?: string;
}
/**
* Reads the database configuration from the app config and, if the client is
* `embedded-postgres`, starts an embedded Postgres instance. Returns the config
* override to inject into the child process, or `undefined` if the database
* client is not `embedded-postgres`.
*/
export async function startEmbeddedDb(options: StartEmbeddedDbOptions): Promise<
| {
configOverride: Record<string, unknown>;
close(): Promise<void>;
}
| undefined
> {
const dbConfig = await readDatabaseConfig(
options.configPaths,
options.targetDir,
);
if (dbConfig?.client !== 'embedded-postgres') {
return undefined;
}
return startEmbeddedDbInternal(dbConfig.connection);
}
async function startEmbeddedDbInternal(
userConfig?: EmbeddedDbConnectionConfig,
) {
console.warn(
chalk.yellow(
'WARNING: Using embedded-postgres for local development is experimental and subject to change',
@@ -107,6 +143,9 @@ export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
throw error;
}
const configOverride: Record<string, unknown> = {
client: 'pg',
};
const defaultedConnection: Record<string, string | number> = {};
if (!userConfig?.host) {
defaultedConnection.host = host;
@@ -120,18 +159,71 @@ export async function startEmbeddedDb(userConfig?: EmbeddedDbConnectionConfig) {
if (!userConfig?.port) {
defaultedConnection.port = port;
}
if (Object.keys(defaultedConnection).length > 0) {
configOverride.connection = defaultedConnection;
}
return {
connection: {
host,
user,
password,
port,
},
defaultedConnection,
configOverride,
async close() {
await pg.stop();
await fs.remove(tmpDir);
},
};
}
async function readDatabaseConfig(
configPaths?: string[],
targetDir?: string,
): Promise<
| {
client: string;
connection?: EmbeddedDbConnectionConfig;
}
| undefined
> {
const rootDir = targetPaths.rootDir;
const configBaseDir = targetDir ?? rootDir;
const source = ConfigSources.default({
rootDir,
allowMissingDefaultConfig: true,
argv: (configPaths ?? []).flatMap(p => [
'--config',
isAbsolutePath(p) ? p : resolvePath(configBaseDir, p),
]),
});
const config = await ConfigSources.toConfig(source);
try {
const client = config.getOptionalString('backend.database.client');
if (!client) {
return undefined;
}
// Only read structured connection config if the value is an object;
// it can also be a plain string (e.g. ':memory:' for better-sqlite3).
const rawConnection = config.getOptional('backend.database.connection');
if (typeof rawConnection === 'string' || rawConnection === undefined) {
return { client };
}
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();
}
}