Files
app-261004/lib/db/index.ts
T
2026-10-08 11:09:37 +08:00

126 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
};
}
}