/** * createDb —— 构造单个 Knex 句柄(领域无关)。 * * 职责单一:解析并校验连接配置、选择 driver、构造 Knex 实例。**不连接**—— * Knex 默认惰性建连,句柄的构造只发生在本函数被调用时,绝不在 import 期。 * 本文件不读 `process.env`:连接串与池配置全部由参数注入。 */ import knexFactory from 'knex' import { DbConfigError } from './errors.js' /** url scheme → Knex client 名映射(仅支持本包声明依赖的 driver)。 */ const SCHEME_TO_CLIENT = { mysql: 'mysql2', mysql2: 'mysql2', } /** * 解析连接 url。当前运行态和测试态都只支持 MySQL。 * @param {string} url * @param {string} [explicitClient] * @returns {{ client: string, connection: any }} */ function parseUrl(url, explicitClient) { if (typeof url !== 'string' || url.trim() === '') { throw new DbConfigError('createDb: cfg.url is required and must be a non-empty string') } let parsed try { parsed = new URL(url) } catch { throw new DbConfigError(`createDb: cfg.url is not a valid URL: ${url}`) } const scheme = parsed.protocol.replace(/:$/, '') const client = explicitClient || (Object.prototype.hasOwnProperty.call(SCHEME_TO_CLIENT, scheme) ? SCHEME_TO_CLIENT[/** @type {keyof typeof SCHEME_TO_CLIENT} */ (scheme)] : undefined) if (!client) { throw new DbConfigError( `createDb: unsupported url scheme "${scheme}". Supported: mysql`, ) } if (!parsed.hostname) { throw new DbConfigError(`createDb: cfg.url missing host: ${url}`) } /** @type {Record} */ const connection = { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 3306, user: decodeURIComponent(parsed.username || ''), password: decodeURIComponent(parsed.password || ''), database: parsed.pathname.replace(/^\//, ''), } const ssl = parseMysqlSsl(parsed) if (ssl) connection.ssl = ssl // 时间纪律(plan/db-root-solution.md 决定 3):所有 DATETIME(3) 值语义固定 UTC。 // timezone 'Z' 让 mysql2 写入序列化 Date、读取构造 Date 都按 UTC,杜绝隐式本地时区转换。 connection.timezone = 'Z' // DECIMAL 和 SUM()/AVG() 的结果 mysql2 默认返回字符串,于是每个读到它的地方都要自己 Number() 一次, // 漏一处就变成字符串参与比较排序('9' > '10')。在驱动这层转干净,业务代码不再关心。 connection.decimalNumbers = true return { client: 'mysql2', connection, } } /** * RDS / 云数据库常见 URL 参数: * - ssl=true / ssl=1 / sslmode=require:开启证书校验。 * - ssl=skip-verify / sslmode=no-verify:只加密不校验证书,限临时联调。 * - ssl=false / sslmode=disable:不启用 TLS。 * @param {URL} parsed * @returns {{ rejectUnauthorized: boolean }|undefined} */ function parseMysqlSsl(parsed) { const raw = parsed.searchParams.get('ssl') || parsed.searchParams.get('sslmode') if (!raw) return undefined const value = raw.toLowerCase() if (value === 'false' || value === '0' || value === 'disable' || value === 'disabled') return undefined if (value === 'skip-verify' || value === 'no-verify' || value === 'insecure') { return { rejectUnauthorized: false } } if ( value === 'true' || value === '1' || value === 'required' || value === 'require' || value === 'verify-ca' || value === 'verify-full' ) { return { rejectUnauthorized: true } } throw new DbConfigError(`createDb: unsupported mysql ssl mode "${raw}"`) } /** * 构造单个 Knex 句柄。连接串由调用方注入,包内不读 env。 * @param {Object} cfg * @param {string} cfg.url 连接串,如 mysql://user:pass@host:3306/db * @param {{min?:number,max?:number}} [cfg.pool] 连接池配置 * @param {'mysql2'} [cfg.client] 显式 client * @returns {import('knex').Knex} Knex 句柄(领域无关) * @throws {DbConfigError} url 缺失 / 非法时立即抛 */ export function createDb(cfg) { if (!cfg || typeof cfg !== 'object') { throw new DbConfigError('createDb: cfg object is required') } const explicitClient = cfg.client === 'mysql2' ? 'mysql2' : undefined const { client, connection } = parseUrl(cfg.url, explicitClient) /** @type {import('knex').Knex.Config} */ const knexConfig = { client, connection } // mysql2 连接池默认:min:0 + 60s 空闲回收 → 空闲连接在 MySQL wait_timeout 断它之前先被池关闭, // 避免「拿到已被服务端断开的连接」偶发 500(client disconnected by inactivity)。可被 cfg.pool 覆盖。 knexConfig.pool = { min: 0, max: 10, idleTimeoutMillis: 60000, // 会话时区固定 UTC:让 DB 侧 NOW(3) 默认值也生成 UTC,配合 connection.timezone 'Z' // 保证读写两端都没有隐式时区转换。 afterCreate: (/** @type {any} */ conn, /** @type {(err: any, c: any) => void} */ done) => { conn.query("SET time_zone = '+00:00'", (/** @type {any} */ err) => done(err, conn)) }, ...(cfg.pool || {}), } // knex() 是工厂调用(非 `new Knex`),且发生在函数体内——不触发连接(惰性)。 return knexFactory(knexConfig) }