create-db.js
5.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/**
* 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)
}