scripts/db/target.ts
Linked repository file from this build.
import { readFileSync, existsSync } from "node:fs";
import { parse } from "dotenv";
export type DatabaseTarget = "development" | "production";
export function resolveDatabaseTarget(
target: string = "development",
env: Record<string, string | undefined> = process.env,
file: Record<string, string | undefined> = existsSync(".env") ? parse(readFileSync(".env")) : {},
) {
if (target !== "development" && target !== "production")
throw new Error("Database target must be development or production.");
const variable = target === "production" ? "PROD_DATABASE_URL" : "DATABASE_URL";
// Read .env explicitly as well: drizzle-kit runs under Node, whereas the CLI uses Bun.
const url = env[variable] ?? file[variable];
if (!url) throw new Error(`${variable} is not configured. Set it once in the ignored .env file.`);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`${variable} must be a PostgreSQL connection URL.`);
}
if (!["postgres:", "postgresql:"].includes(parsed.protocol))
throw new Error(`${variable} must be a PostgreSQL connection URL.`);
// Neon transaction pooling does not preserve the session lock/settings used by migrations.
if (parsed.hostname.endsWith(".neon.tech"))
parsed.hostname = parsed.hostname.replace("-pooler.", ".");
// Preserve pg 8's certificate verification without its deprecated sslmode aliases.
if (
!parsed.searchParams.has("uselibpqcompat") &&
["prefer", "require", "verify-ca"].includes(parsed.searchParams.get("sslmode") ?? "")
)
parsed.searchParams.set("sslmode", "verify-full");
return {
target: target as DatabaseTarget,
variable,
url: parsed.toString(),
label: `${parsed.hostname}:${parsed.port || "5432"}${parsed.pathname}`,
};
}
/** Destructive production commands need a flag typed by hand. */
export function assertProductionConfirmed(target: string, confirmed: boolean, effect: string) {
if (target === "production" && !confirmed)
throw new Error(`${effect} on production. Run again with --confirm-production.`);
}
/** Never include connection strings or their passwords in terminal/file diagnostics. */
export function redact(text: string, urls: string[] = []) {
let safe = text;
for (const url of urls.filter(Boolean)) {
safe = safe.split(url).join("[redacted database URL]");
try {
const password = new URL(url).password;
for (const value of new Set([password, decodeURIComponent(password)]))
if (value) safe = safe.split(value).join("[redacted]");
} catch {
/* Invalid URLs are still redacted verbatim above. */
}
}
return safe.replace(/postgres(?:ql)?:\/\/[^\s"'<>]+/gi, "[redacted database URL]");
}
export function errorDetails(error: unknown, urls: string[] = []) {
const chain: Record<string, string>[] = [];
const seen = new Set<unknown>();
for (let cause: unknown = error; cause && !seen.has(cause);) {
seen.add(cause);
const row: Record<string, string> = {};
if (typeof cause !== "object") {
row.message = redact(String(cause), urls);
chain.push(row);
break;
}
const value = cause as Record<string, unknown>;
for (const key of [
"name",
"message",
"code",
"severity",
"detail",
"hint",
"schema",
"table",
"column",
"constraint",
"position",
"where",
"routine",
])
if (value[key] != null && (key !== "column" || typeof value[key] === "string"))
row[key] = redact(String(value[key]), urls);
chain.push(row);
cause = value.cause;
}
return chain;
}