187 lines
5.8 KiB
TypeScript
187 lines
5.8 KiB
TypeScript
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { createServer } from "node:net";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { ensurePostgresDatabase } from "./client.js";
|
|
import { resolveDatabaseTarget } from "./runtime-config.js";
|
|
|
|
type EmbeddedPostgresInstance = {
|
|
initialise(): Promise<void>;
|
|
start(): Promise<void>;
|
|
stop(): Promise<void>;
|
|
};
|
|
|
|
type EmbeddedPostgresCtor = new (opts: {
|
|
databaseDir: string;
|
|
user: string;
|
|
password: string;
|
|
port: number;
|
|
persistent: boolean;
|
|
initdbFlags?: string[];
|
|
onLog?: (message: unknown) => void;
|
|
onError?: (message: unknown) => void;
|
|
}) => EmbeddedPostgresInstance;
|
|
|
|
export type MigrationConnection = {
|
|
connectionString: string;
|
|
source: string;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
function toError(error: unknown, fallbackMessage: string): Error {
|
|
if (error instanceof Error) return error;
|
|
if (error === undefined) return new Error(fallbackMessage);
|
|
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
|
|
|
|
try {
|
|
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
|
|
} catch {
|
|
return new Error(`${fallbackMessage}: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
|
if (!existsSync(postmasterPidFile)) return null;
|
|
try {
|
|
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
process.kill(pid, 0);
|
|
return pid;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readPidFilePort(postmasterPidFile: string): number | null {
|
|
if (!existsSync(postmasterPidFile)) return null;
|
|
try {
|
|
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
|
|
const port = Number(lines[3]?.trim());
|
|
return Number.isInteger(port) && port > 0 ? port : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function isPortInUse(port: number): Promise<boolean> {
|
|
return await new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
resolve(error.code === "EADDRINUSE");
|
|
});
|
|
server.listen(port, "127.0.0.1", () => {
|
|
server.close();
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function findAvailablePort(startPort: number): Promise<number> {
|
|
const maxLookahead = 20;
|
|
let port = startPort;
|
|
for (let i = 0; i < maxLookahead; i += 1, port += 1) {
|
|
if (!(await isPortInUse(port))) return port;
|
|
}
|
|
throw new Error(
|
|
`Embedded PostgreSQL could not find a free port from ${startPort} to ${startPort + maxLookahead - 1}`,
|
|
);
|
|
}
|
|
|
|
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
|
const require = createRequire(import.meta.url);
|
|
const resolveCandidates = [
|
|
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
|
|
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
|
|
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
|
|
process.cwd(),
|
|
];
|
|
|
|
try {
|
|
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
|
|
const mod = await import(pathToFileURL(resolvedModulePath).href);
|
|
return mod.default as EmbeddedPostgresCtor;
|
|
} catch {
|
|
throw new Error(
|
|
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function ensureEmbeddedPostgresConnection(
|
|
dataDir: string,
|
|
preferredPort: number,
|
|
): Promise<MigrationConnection> {
|
|
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
|
|
const selectedPort = await findAvailablePort(preferredPort);
|
|
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
|
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
|
const runningPort = readPidFilePort(postmasterPidFile);
|
|
|
|
if (runningPid) {
|
|
const port = runningPort ?? preferredPort;
|
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
|
return {
|
|
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
|
|
source: `embedded-postgres@${port}`,
|
|
stop: async () => {},
|
|
};
|
|
}
|
|
|
|
const instance = new EmbeddedPostgres({
|
|
databaseDir: dataDir,
|
|
user: "paperclip",
|
|
password: "paperclip",
|
|
port: selectedPort,
|
|
persistent: true,
|
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
|
onLog: () => {},
|
|
onError: () => {},
|
|
});
|
|
|
|
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
|
try {
|
|
await instance.initialise();
|
|
} catch (error) {
|
|
throw toError(
|
|
error,
|
|
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
|
|
);
|
|
}
|
|
}
|
|
if (existsSync(postmasterPidFile)) {
|
|
rmSync(postmasterPidFile, { force: true });
|
|
}
|
|
try {
|
|
await instance.start();
|
|
} catch (error) {
|
|
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
|
|
}
|
|
|
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;
|
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
|
|
|
return {
|
|
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/paperclip`,
|
|
source: `embedded-postgres@${selectedPort}`,
|
|
stop: async () => {
|
|
await instance.stop();
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function resolveMigrationConnection(): Promise<MigrationConnection> {
|
|
const target = resolveDatabaseTarget();
|
|
if (target.mode === "postgres") {
|
|
return {
|
|
connectionString: target.connectionString,
|
|
source: target.source,
|
|
stop: async () => {},
|
|
};
|
|
}
|
|
|
|
return ensureEmbeddedPostgresConnection(target.dataDir, target.port);
|
|
}
|