126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import "server-only";
|
||
|
||
import { drizzle } from "drizzle-orm/node-postgres";
|
||
import { Pool } from "pg";
|
||
import * as schema from "./schema";
|
||
|
||
/**
|
||
* 数据库客户端(node-postgres 连接池)。
|
||
*
|
||
* ## 为什么用 pg 而不是 neon-http
|
||
*
|
||
* 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
|
||
* 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 Postgres 的 TCP 协议,
|
||
* 因此无法连接本地或自建的普通 Postgres。
|
||
*
|
||
* 本项目改用 `pg`(node-postgres),它既能连本地 Postgres,
|
||
* 也能连 Neon / Supabase / Railway 等任何标准 Postgres——
|
||
* 部署到 Neon 时只需保留其 TCP 连接串(`*.neon.tech` 的 5432 端口)。
|
||
*
|
||
* ## 连接池与 Next.js 开发模式
|
||
*
|
||
* Next.js 开发模式下模块会被热重载,若不缓存连接池,每次改动都会新建一批
|
||
* 连接,很快耗尽数据库的 max_connections。因此把 pool 挂到 globalThis 上做单例。
|
||
*/
|
||
|
||
const connectionString = process.env.DATABASE_URL;
|
||
|
||
if (!connectionString) {
|
||
throw new Error(
|
||
"缺少环境变量 DATABASE_URL。请在 .env.local 中配置 Postgres 连接串," +
|
||
"例如:postgresql://user:password@localhost:5432/blog",
|
||
);
|
||
}
|
||
|
||
// 收窄为 string,供下方闭包使用(模块级已判空,但闭包内 TS 无法自动收窄)
|
||
const resolvedConnectionString: string = connectionString;
|
||
|
||
/** 在 globalThis 上缓存连接池,避免开发模式热重载导致的连接泄漏 */
|
||
const globalForDb = globalThis as unknown as {
|
||
__postgresPool?: Pool;
|
||
};
|
||
|
||
function createPool(): Pool {
|
||
const pool = new Pool({
|
||
connectionString: resolvedConnectionString,
|
||
// 本地开发(localhost)通常没配证书,强制 SSL 会直接失败;
|
||
// 托管数据库(Neon / Supabase 等)则要求 SSL。
|
||
ssl: shouldUseSsl(resolvedConnectionString)
|
||
? { rejectUnauthorized: false }
|
||
: undefined,
|
||
// 单实例默认 10 连接足够;Serverless 环境建议调小或接 Neon 的 pooler
|
||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||
idleTimeoutMillis: 30_000,
|
||
connectionTimeoutMillis: 10_000,
|
||
// 语句级超时,避免慢查询长期占用连接
|
||
statement_timeout: Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15_000),
|
||
});
|
||
|
||
// 连接池错误不应导致进程崩溃(例如数据库重启期间的瞬时失败)
|
||
pool.on("error", (error) => {
|
||
console.error("[db] 空闲连接异常:", error.message);
|
||
});
|
||
|
||
return pool;
|
||
}
|
||
|
||
/** 判断是否需要启用 SSL:本地地址不启用,远端默认启用 */
|
||
function shouldUseSsl(url: string): boolean {
|
||
try {
|
||
const { hostname, searchParams } = new URL(url);
|
||
const sslmode = searchParams.get("sslmode");
|
||
|
||
// 显式声明优先
|
||
if (sslmode === "disable") {
|
||
return false;
|
||
}
|
||
if (sslmode === "require" || sslmode === "verify-full") {
|
||
return true;
|
||
}
|
||
|
||
// 未显式声明时,按主机推断
|
||
const isLocal =
|
||
hostname === "localhost" ||
|
||
hostname === "127.0.0.1" ||
|
||
hostname === "::1" ||
|
||
hostname.endsWith(".local");
|
||
|
||
return !isLocal;
|
||
} catch {
|
||
// URL 解析失败时交给 pg 自己报错,这里默认不启用 SSL
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const pool = globalForDb.__postgresPool ?? createPool();
|
||
|
||
// 生产环境不需要挂载(模块不会热重载),但挂载也无副作用
|
||
globalForDb.__postgresPool = pool;
|
||
|
||
export const db = drizzle(pool, { schema });
|
||
|
||
/** 底层连接池,供健康检查与脚本使用 */
|
||
export { pool };
|
||
|
||
export type DB = typeof db;
|
||
|
||
/** 检查数据库连通性,用于健康检查接口 */
|
||
export async function checkDatabaseHealth(): Promise<{
|
||
ok: boolean;
|
||
latencyMs: number;
|
||
error?: string;
|
||
}> {
|
||
const startedAt = Date.now();
|
||
|
||
try {
|
||
await pool.query("select 1");
|
||
return { ok: true, latencyMs: Date.now() - startedAt };
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
latencyMs: Date.now() - startedAt,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|