Merge pull request #33714 from backstage/rugvip/dev-db

cli: experimental embedded-postgres support for local dev
This commit is contained in:
Patrik Oldsberg
2026-04-03 21:07:15 +02:00
committed by GitHub
8 changed files with 377 additions and 41 deletions
+1 -1
View File
@@ -585,7 +585,7 @@ export interface Config {
/** Database connection configuration, select base database type using the `client` field */
database: {
/** Default database client to use */
client: 'better-sqlite3' | 'sqlite3' | 'pg';
client: 'better-sqlite3' | 'sqlite3' | 'pg' | 'embedded-postgres';
/**
* Base database connection string, or object with individual connection properties
* @visibility secret
+11 -1
View File
@@ -77,6 +77,7 @@
"node-stdlib-browser": "^1.3.1",
"npm-packlist": "^5.0.0",
"p-queue": "^6.6.2",
"portfinder": "^1.0.32",
"postcss": "^8.1.0",
"postcss-import": "^16.1.0",
"process": "^0.11.10",
@@ -106,6 +107,15 @@
"@types/fs-extra": "^11.0.0",
"@types/lodash": "^4.14.151",
"@types/npm-packlist": "^3.0.0",
"@types/shell-quote": "^1.7.5"
"@types/shell-quote": "^1.7.5",
"embedded-postgres": "18.3.0-beta.16"
},
"peerDependencies": {
"embedded-postgres": "^18.3.0-beta.16"
},
"peerDependenciesMeta": {
"embedded-postgres": {
"optional": true
}
}
}
@@ -23,6 +23,7 @@ import { runBackend } from '../../../lib/runner';
interface StartBackendOptions {
targetDir: string;
checksEnabled: boolean;
configPaths?: string[];
inspectEnabled?: boolean | string;
inspectBrkEnabled?: boolean | string;
linkedWorkspace?: string;
@@ -33,6 +34,7 @@ export async function startBackend(options: StartBackendOptions) {
const waitForExit = await runBackend({
targetDir: options.targetDir,
entry: 'src/index',
configPaths: options.configPaths,
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
linkedWorkspace: options.linkedWorkspace,
@@ -56,6 +58,7 @@ export async function startBackendPlugin(options: StartBackendOptions) {
const waitForExit = await runBackend({
targetDir: options.targetDir,
entry: 'dev/index',
configPaths: options.configPaths,
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
require: options.require,
@@ -49,6 +49,21 @@ jest.mock('ctrlc-windows', () => ({
ctrlc: jest.fn(),
}));
const mockToConfig = jest.fn();
jest.mock('@backstage/config-loader', () => ({
ConfigSources: {
default: () => ({}),
toConfig: (...args: any[]) => mockToConfig(...args),
},
}));
const mockStartEmbeddedDb = jest.fn();
jest.mock('./startEmbeddedDb', () => ({
startEmbeddedDb: (...args: any[]) => mockStartEmbeddedDb(...args),
}));
describe('runBackend', () => {
let originalEnv: NodeJS.ProcessEnv;
let originalPlatform: string;
@@ -68,6 +83,12 @@ describe('runBackend', () => {
// Mock process.once to prevent actual signal handling
jest.spyOn(process, 'once').mockReturnValue(process);
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: () => undefined,
});
mockStartEmbeddedDb.mockReset();
});
afterEach(() => {
@@ -82,92 +103,73 @@ describe('runBackend', () => {
});
describe('--no-node-snapshot argument handling', () => {
it('should pass --no-node-snapshot when NODE_OPTIONS is not set', () => {
it('should pass --no-node-snapshot when NODE_OPTIONS is not set', async () => {
delete process.env.NODE_OPTIONS;
runBackend({
entry: 'src/index',
});
runBackend({ entry: 'src/index' });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', () => {
it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', async () => {
process.env.NODE_OPTIONS = '--max-old-space-size=4096';
runBackend({
entry: 'src/index',
});
runBackend({ entry: 'src/index' });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', () => {
it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', async () => {
process.env.NODE_OPTIONS = '--node-snapshot --max-old-space-size=4096';
runBackend({
entry: 'src/index',
});
runBackend({ entry: 'src/index' });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).not.toContain('--no-node-snapshot');
});
it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', () => {
it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', async () => {
process.env.NODE_OPTIONS =
'--max-old-space-size=4096 --node-snapshot --inspect';
runBackend({
entry: 'src/index',
});
runBackend({ entry: 'src/index' });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).not.toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', () => {
it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', async () => {
process.env.NODE_OPTIONS = '--max-old-space-size=4096 ';
runBackend({
entry: 'src/index',
});
runBackend({ entry: 'src/index' });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot alongside other option args like --inspect', () => {
it('should pass --no-node-snapshot alongside other option args like --inspect', async () => {
delete process.env.NODE_OPTIONS;
runBackend({
entry: 'src/index',
inspectEnabled: true,
});
runBackend({ entry: 'src/index', inspectEnabled: true });
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
await jest.advanceTimersByTimeAsync(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
@@ -175,4 +177,62 @@ describe('runBackend', () => {
expect(spawnArgs).toContain('--inspect');
});
});
describe('embedded-postgres support', () => {
it('should start embedded DB and inject config when database client is embedded-postgres', async () => {
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'embedded-postgres' : undefined,
});
mockStartEmbeddedDb.mockResolvedValue({
connection: {
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();
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: {
host: 'localhost',
user: 'postgres',
password: 'password',
port: 5555,
},
});
});
it('should not start embedded DB for other database clients', async () => {
mockToConfig.mockResolvedValue({
close: jest.fn(),
getOptionalString: (key: string) =>
key === 'backend.database.client' ? 'better-sqlite3' : 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
>;
expect(spawnEnv.APP_CONFIG_backend_database).toBeUndefined();
});
});
});
@@ -20,10 +20,15 @@ import { ctrlc } from 'ctrlc-windows';
import { IpcServer, ServerDataStore } from '../ipc';
import debounce from 'lodash/debounce';
import { fileURLToPath } from 'node:url';
import { isAbsolute as isAbsolutePath } from 'node:path';
import {
isAbsolute as isAbsolutePath,
resolve as resolvePath,
} from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { ConfigSources } from '@backstage/config-loader';
import spawn from 'cross-spawn';
import { startEmbeddedDb } from './startEmbeddedDb';
const loaderArgs = [
'--enable-source-maps',
@@ -45,6 +50,8 @@ export type RunBackendOptions = {
require?: string | string[];
/** An external linked workspace to override module resolution towards */
linkedWorkspace?: string;
/** Config file paths from --config flags */
configPaths?: string[];
};
export async function runBackend(options: RunBackendOptions) {
@@ -57,6 +64,19 @@ export async function runBackend(options: RunBackendOptions) {
const server = new IpcServer();
ServerDataStore.bind(server);
const extraEnv: Record<string, string> = {};
let embeddedDb: Awaited<ReturnType<typeof startEmbeddedDb>> | undefined;
const dbClient = await readDatabaseClient(options.configPaths);
if (dbClient === 'embedded-postgres') {
embeddedDb = await startEmbeddedDb();
extraEnv.APP_CONFIG_backend_database = JSON.stringify({
client: 'pg',
connection: embeddedDb.connection,
});
}
let exiting = false;
let firstStart = true;
let child: ChildProcess | undefined;
@@ -134,6 +154,7 @@ export async function runBackend(options: RunBackendOptions) {
cwd: options.targetDir,
env: {
...process.env,
...extraEnv,
BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace,
BACKSTAGE_CLI_CHANNEL: '1',
ESBK_TSCONFIG_PATH: targetPaths.resolveRoot('tsconfig.json'),
@@ -186,6 +207,7 @@ export async function runBackend(options: RunBackendOptions) {
});
}
await embeddedDb?.close();
resolveExitPromise();
}
@@ -195,3 +217,24 @@ export async function runBackend(options: RunBackendOptions) {
return () => exitPromise;
}
async function readDatabaseClient(
configPaths?: string[],
): Promise<string | undefined> {
const rootDir = targetPaths.rootDir;
const source = ConfigSources.default({
rootDir,
allowMissingDefaultConfig: true,
argv: (configPaths ?? []).flatMap(p => [
'--config',
isAbsolutePath(p) ? p : resolvePath(rootDir, p),
]),
});
const config = await ConfigSources.toConfig(source);
try {
return config.getOptionalString('backend.database.client');
} finally {
config.close();
}
}
@@ -0,0 +1,116 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'node:os';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { getPortPromise } from 'portfinder';
import { ForwardedError } from '@backstage/errors';
import chalk from 'chalk';
const TEMP_DIR_PREFIX = 'backstage-dev-db-';
const PID_FILE = 'backstage.pid';
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function cleanStaleDatabases() {
const tmpBase = os.tmpdir();
const entries = (await fs.readdir(tmpBase)).filter(d =>
d.startsWith(TEMP_DIR_PREFIX),
);
await Promise.all(
entries.map(async d => {
const dir = resolvePath(tmpBase, d);
const raw = await fs
.readFile(resolvePath(dir, PID_FILE), 'utf8')
.catch(() => undefined);
const pid = raw ? Number(raw.trim()) : NaN;
if (!pid || !isProcessAlive(pid)) {
await fs.remove(dir);
}
}),
);
}
export async function startEmbeddedDb() {
console.warn(
chalk.yellow(
'WARNING: Using embedded-postgres for local development is experimental and subject to change',
),
);
const { default: EmbeddedPostgres } = await import('embedded-postgres').catch(
error => {
throw new ForwardedError(
`Failed to load 'embedded-postgres' which is required when using ` +
`'embedded-postgres' as the database client. It must be installed ` +
`as an explicit dependency in your project`,
error,
);
},
);
await cleanStaleDatabases();
const host = 'localhost';
const user = 'postgres';
const password = 'password';
const port = await getPortPromise();
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), TEMP_DIR_PREFIX));
await fs.writeFile(resolvePath(tmpDir, PID_FILE), String(process.pid));
const pg = new EmbeddedPostgres({
databaseDir: tmpDir,
user,
password,
port,
persistent: false,
onError(messageOrError) {
console.error(`[embedded-postgres]`, messageOrError);
},
onLog() {},
});
try {
await pg.initialise();
await pg.start();
} catch (error) {
await pg.stop().catch(() => {});
await fs.remove(tmpDir).catch(() => {});
throw error;
}
return {
connection: {
host,
user,
password,
port,
},
async close() {
await pg.stop();
await fs.remove(tmpDir);
},
};
}