use std::collections::BTreeSet;
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
const MIGRATION_TEMPLATE_PREFIX: &str = "-- Noxid forward migration: ";
const MIGRATION_RUNNER_SOURCE: &str = include_str!("../../../tools/noxid-db.mjs");
const DATABASE_URL_SOURCE: &str = include_str!("../../../tools/database-url.mjs");
const NODE_SQLITE_SOURCE: &str = include_str!("../../../tools/node-sqlite.mjs");
static RUNNER_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EmbeddedMigrationRunner {
directory: PathBuf,
entry: PathBuf,
}
impl EmbeddedMigrationRunner {
fn prepare() -> Result<Self, String> {
for _ in 0..100 {
let suffix = RUNNER_COUNTER.fetch_add(1, Ordering::Relaxed);
let directory =
env::temp_dir().join(format!("noxid-db-runner-{}-{suffix}", std::process::id()));
match fs::create_dir(&directory) {
Ok(()) => {
let entry = directory.join("noxid-db.mjs");
for (name, source) in [
("noxid-db.mjs", MIGRATION_RUNNER_SOURCE),
("database-url.mjs", DATABASE_URL_SOURCE),
("node-sqlite.mjs", NODE_SQLITE_SOURCE),
] {
if let Err(error) = fs::write(directory.join(name), source) {
let _ = fs::remove_dir_all(&directory);
return Err(format!(
"cannot prepare embedded Node.js migration runner: {error}"
));
}
}
return Ok(Self { directory, entry });
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"cannot create temporary directory for the embedded Node.js migration runner: {error}"
));
}
}
}
Err("cannot allocate a unique temporary directory for the embedded Node.js migration runner".into())
}
fn entry(&self) -> &Path {
&self.entry
}
}
impl Drop for EmbeddedMigrationRunner {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
pub fn run(mut args: impl Iterator<Item = String>) -> Result<(), String> {
let operation = args.next().ok_or_else(db_usage)?;
let project_root = find_project_root(&env::current_dir().map_err(|error| {
format!("cannot determine the current directory for `noxid db`: {error}")
})?)?;
match operation.as_str() {
"new" => {
let name = args
.next()
.ok_or("noxid db new requires a lowercase migration name")?;
if args.next().is_some() {
return Err("noxid db new accepts exactly one migration name".into());
}
let path = scaffold_migration(&project_root, &name)?;
println!("created migration -> {}", path.display());
Ok(())
}
"migrate" | "status" => {
if args.next().is_some() {
return Err(format!("noxid db {operation} does not accept arguments"));
}
run_database(operation.as_str(), &project_root)
}
other => Err(format!(
"unknown noxid db operation `{other}`\n{}",
db_usage()
)),
}
}
fn db_usage() -> String {
"usage: noxid db new <name>\n noxid db migrate\n noxid db status".into()
}
fn find_project_root(start: &Path) -> Result<PathBuf, String> {
let start = fs::canonicalize(start)
.map_err(|error| format!("cannot resolve {}: {error}", start.display()))?;
start
.ancestors()
.find(|candidate| candidate.join("Noxid.toml").is_file())
.map(Path::to_path_buf)
.ok_or_else(|| {
format!(
"`noxid db` must run inside a Noxid project containing Noxid.toml (started at {})",
start.display()
)
})
}
fn scaffold_migration(project_root: &Path, name: &str) -> Result<PathBuf, String> {
validate_migration_name(name)?;
let directory = prepare_migration_directory(project_root)?;
let next = next_migration_number(&directory)?;
let filename = format!("{next:04}-{name}.sql");
let path = directory.join(&filename);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| format!("cannot create migration {}: {error}", path.display()))?;
file.write_all(format!("{MIGRATION_TEMPLATE_PREFIX}{filename}\n\n").as_bytes())
.map_err(|error| format!("cannot write migration {}: {error}", path.display()))?;
Ok(path)
}
fn prepare_migration_directory(project_root: &Path) -> Result<PathBuf, String> {
let project_root = fs::canonicalize(project_root).map_err(|error| {
format!(
"cannot resolve Noxid project root {}: {error}",
project_root.display()
)
})?;
let mut directory = project_root.clone();
for component in ["server", "db", "migrations"] {
let candidate = directory.join(component);
match fs::symlink_metadata(&candidate) {
Ok(metadata)
if !metadata.file_type().is_dir() && !metadata.file_type().is_symlink() =>
{
return Err(format!(
"migration directory component {} is not a directory; use a real directory under {}",
candidate.display(),
project_root.display()
));
}
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {
fs::create_dir(&candidate).map_err(|error| {
format!(
"cannot create migration directory {}: {error}",
candidate.display()
)
})?;
}
Err(error) => {
return Err(format!(
"cannot inspect migration directory {}: {error}",
candidate.display()
));
}
}
let resolved = fs::canonicalize(&candidate).map_err(|error| {
format!(
"cannot resolve migration directory {}: {error}",
candidate.display()
)
})?;
if !resolved.starts_with(&project_root) {
return Err(format!(
"refusing migration directory {} because it resolves outside the Noxid project {}; replace the symlink with a directory inside the project",
candidate.display(),
project_root.display()
));
}
directory = resolved;
}
Ok(directory)
}
fn validate_migration_name(name: &str) -> Result<(), String> {
let mut previous_dash = true;
if name.is_empty()
|| !name.bytes().all(|byte| {
let valid = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-';
let placement = byte != b'-' || !previous_dash;
previous_dash = byte == b'-';
valid && placement
})
|| previous_dash
{
return Err(format!(
"invalid migration name `{name}`; use lowercase letters, digits, and single dashes (for example `add-user-index`)"
));
}
Ok(())
}
fn next_migration_number(directory: &Path) -> Result<u16, String> {
let mut highest = 0_u16;
let mut sequences = BTreeSet::new();
let entries = fs::read_dir(directory).map_err(|error| {
format!(
"cannot inspect migration directory {}: {error}",
directory.display()
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"cannot inspect migration directory {}: {error}",
directory.display()
)
})?;
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if !file_name.ends_with(".sql") {
continue;
}
if !entry
.file_type()
.map_err(|error| format!("cannot inspect migration `{file_name}`: {error}"))?
.is_file()
{
return Err(format!(
"invalid migration `{file_name}`; ordered SQL migrations must be regular files"
));
}
let number = parse_migration_filename(&file_name)?;
if !sequences.insert(number) {
return Err(format!(
"duplicate migration sequence {number:04}; every ordered SQL migration needs a unique sequence"
));
}
highest = highest.max(number);
}
highest
.checked_add(1)
.filter(|number| *number <= 9_999)
.ok_or_else(|| "migration sequence exhausted at 9999; archive into a new project".into())
}
fn parse_migration_filename(file_name: &str) -> Result<u16, String> {
let (number, rest) = file_name.split_once('-').ok_or_else(|| {
format!("invalid migration filename `{file_name}`; expected `NNNN-lowercase-name.sql`")
})?;
if number.len() != 4 || !number.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!(
"invalid migration filename `{file_name}`; expected a four-digit sequence"
));
}
let name = rest.strip_suffix(".sql").ok_or_else(|| {
format!("invalid migration filename `{file_name}`; expected a `.sql` suffix")
})?;
validate_migration_name(name).map_err(|_| {
format!("invalid migration filename `{file_name}`; expected `NNNN-lowercase-name.sql`")
})?;
number
.parse::<u16>()
.map_err(|error| format!("invalid migration sequence in `{file_name}`: {error}"))
}
fn run_database(operation: &str, project_root: &Path) -> Result<(), String> {
if env::var_os("DATABASE_URL").is_none() {
return Err(format!(
"noxid db {operation} requires DATABASE_URL; migrations never infer or persist database credentials"
));
}
let policies = crate::data_security::discover_project_schema_policies(project_root)?;
let policies_json = crate::data_security::policies_json(&policies);
let runner = EmbeddedMigrationRunner::prepare()?;
let status = Command::new("node")
.arg(runner.entry())
.arg(operation)
.arg(project_root)
.current_dir(project_root)
.env("NOXID_DATA_POLICIES", policies_json)
.status()
.map_err(|error| format!("cannot start Node.js migration runner: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!(
"database migration runner failed{}",
status
.code()
.map(|code| format!(" with exit code {code}"))
.unwrap_or_default()
))
}
}
pub(crate) fn deployment_warnings(input: &Path) -> Vec<String> {
if env::var_os("DATABASE_URL").is_none() {
return Vec::new();
}
let project_root = if input.is_dir() {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
let Ok(runner) = EmbeddedMigrationRunner::prepare() else {
return Vec::new();
};
let Ok(output) = Command::new("node")
.arg(runner.entry())
.arg("status")
.arg(project_root)
.current_dir(project_root)
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let pending = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.strip_prefix("pending "))
.map(str::to_string)
.collect::<Vec<_>>();
if pending.is_empty() {
Vec::new()
} else {
vec![format!(
"pending database migrations: {}; run `noxid db migrate` as the platform release command before serving this build",
pending.join(", ")
)]
}
}
#[cfg(test)]
mod tests {
use super::{next_migration_number, scaffold_migration, validate_migration_name};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_project(name: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
let root =
std::env::temp_dir().join(format!("noxid-db-{name}-{}-{nonce}", std::process::id()));
fs::create_dir_all(&root).expect("create temporary project");
root
}
#[test]
fn migration_names_are_a_closed_portable_slug() {
for valid in ["create-users", "v2-index", "a"] {
validate_migration_name(valid).expect("valid migration name");
}
for invalid in [
"",
"Create-users",
"create_users",
"../escape",
"two--dashes",
"end-",
] {
assert!(
validate_migration_name(invalid).is_err(),
"accepted `{invalid}`"
);
}
}
#[test]
fn scaffolding_starts_at_one_and_uses_the_highest_existing_sequence() {
let root = temp_project("sequence");
let first = scaffold_migration(&root, "create-users").expect("first migration");
assert!(first.ends_with("server/db/migrations/0001-create-users.sql"));
fs::write(
root.join("server/db/migrations/0007-existing.sql"),
"SELECT 1;\n",
)
.expect("write existing migration");
fs::write(root.join("server/db/migrations/README.md"), "notes\n")
.expect("write non-SQL file");
let next = scaffold_migration(&root, "add-index").expect("next migration");
assert!(next.ends_with("server/db/migrations/0008-add-index.sql"));
let text = fs::read_to_string(next).expect("read scaffold");
assert!(text.starts_with("-- Noxid forward migration: 0008-add-index.sql\n"));
fs::remove_dir_all(root).expect("remove temporary project");
}
#[test]
fn malformed_sql_filenames_fail_closed_instead_of_being_skipped() {
let root = temp_project("malformed");
fs::write(root.join("bad.sql"), "SELECT 1;\n").expect("write malformed migration");
let error = next_migration_number(&root).expect_err("malformed SQL must fail");
assert!(error.contains("invalid migration filename `bad.sql`"));
fs::remove_dir_all(root).expect("remove temporary project");
}
#[test]
fn duplicate_sequences_fail_closed() {
let root = temp_project("duplicate");
fs::write(root.join("0001-first.sql"), "SELECT 1;\n").expect("write first migration");
fs::write(root.join("0001-other.sql"), "SELECT 2;\n").expect("write duplicate migration");
let error = next_migration_number(&root).expect_err("duplicate sequence must fail");
assert!(error.contains("duplicate migration sequence 0001"));
fs::remove_dir_all(root).expect("remove temporary project");
}
#[cfg(unix)]
#[test]
fn scaffolding_refuses_an_escaping_migration_directory_symlink() {
use std::os::unix::fs::symlink;
let root = temp_project("escaping-symlink");
let outside = temp_project("escaping-symlink-target");
fs::create_dir_all(root.join("server/db")).expect("create migration parent");
symlink(&outside, root.join("server/db/migrations")).expect("create migration symlink");
let error = scaffold_migration(&root, "must-stay-inside")
.expect_err("escaping migration symlink must fail closed");
assert!(error.contains("resolves outside the Noxid project"));
assert!(!outside.join("0001-must-stay-inside.sql").exists());
fs::remove_dir_all(root).expect("remove temporary project");
fs::remove_dir_all(outside).expect("remove outside directory");
}
}