create-db.js 5.16 KB
/**
 * 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<string, any>} */
  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)
}