apply-ddl.mjs
5.6 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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
* Parse dotenv-style text into a plain object.
*
* Rules:
* - one `KEY=VALUE` per line
* - blank lines and full-line comments (first non-space char is `#`) are skipped
* - an optional leading `export ` is stripped
* - key and value are trimmed
* - a single layer of matching surrounding quotes ('...' or "...") is removed
* - NO variable expansion: `$FOO`, `${FOO}`, `$(...)`, backticks stay literal
*
* @param {string} text
* @returns {Record<string, string>}
*/
export function parseEnv(text) {
const env = {}
if (typeof text !== 'string') return env
for (const rawLine of text.split('\n')) {
let line = rawLine.replace(/\r$/, '') // tolerate CRLF
const trimmed = line.trim()
if (trimmed === '' || trimmed.startsWith('#')) continue
// strip an optional `export ` prefix (off the trimmed-left view)
let body = line.replace(/^\s*export\s+/, '')
const eq = body.indexOf('=')
if (eq === -1) continue // not a KEY=VALUE line; ignore
const key = body.slice(0, eq).trim()
if (key === '') continue
let value = body.slice(eq + 1).trim()
// remove one layer of matching surrounding quotes, if present.
if (
value.length >= 2 &&
((value[0] === '"' && value[value.length - 1] === '"') ||
(value[0] === "'" && value[value.length - 1] === "'"))
) {
value = value.slice(1, -1)
}
// NOTE: no variable expansion is performed — value is inserted literally.
env[key] = value
}
return env
}
/**
* Apply a DDL file to a MySQL database using mysql2/promise.
*
* @param {{envPath: string, ddlPath: string}} opts
* @returns {Promise<void>}
*/
export async function applyDDL({ envPath, ddlPath }) {
const { readFileSync } = await import('node:fs')
const env = parseEnv(readFileSync(envPath, 'utf8'))
const ddl = readFileSync(ddlPath, 'utf8')
const { host, port, user, password, database } = resolveDbConfig(env, envPath)
assertSafeDbTarget({ host, database, env, label: 'apply-ddl' })
let mysql
try {
;({ default: mysql } = await import('mysql2/promise'))
} catch {
throw new MysqlUnavailableError()
}
const conn = await mysql.createConnection({
host,
port,
user,
password,
database,
multipleStatements: true,
})
try {
await conn.query(ddl)
} finally {
await conn.end()
}
}
/**
* Resolve mysql2 connection settings from a parsed env object. Pure (no I/O),
* so it is unit-testable without mysql2 installed.
*
* Throws if no schema resolves — V1 has no USE/CREATE DATABASE.
*
* @param {Record<string,string>} env
* @param {string} [envPath] only used to make the error message actionable
* @returns {{host:string, port:number, user:string, password:string, database:string}}
*/
export function resolveDbConfig(env, envPath = '.env.local') {
const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1'
const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306)
const user = env.DB_USER || env.MYSQL_USER || 'root'
const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || ''
const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined
if (!database) {
throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`)
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`)
}
return { host, port, user, password, database }
}
/**
* Fail closed for direct DDL application. setup-test-db.mjs has the same guard
* before DROP+CREATE; apply-ddl repeats it so direct CLI use cannot hit prod.
*
* @param {{host:string, database:string, env?:Record<string,string>, label?:string}} opts
* @returns {true}
*/
export function assertSafeDbTarget({ host, database, env = {}, label = 'apply-ddl' }) {
const extraHosts = String(env.TEST_DB_ALLOWED_HOSTS || '')
.split(/[\s,]+/)
.filter(Boolean)
const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts]
if (!allowedHosts.includes(host)) {
throw new Error(`${label}: 拒绝连接非白名单 host (${host});如确认是测试库,请在 .env.local 设置 TEST_DB_ALLOWED_HOSTS`)
}
if (!/^[A-Za-z0-9_]+$/.test(database)) {
throw new Error(`${label}: DB_SCHEMA 只能包含字母、数字、下划线,当前为 ${JSON.stringify(database)}`)
}
const looksLikeTest = /test/i.test(database) || /_dev$/i.test(database) || /_local$/i.test(database) || /_ci$/i.test(database)
if (!looksLikeTest) {
throw new Error(`${label}: schema '${database}' 不像测试/开发库(需含 test 或以 _dev/_local/_ci 结尾)`)
}
return true
}
/** Distinct error type so the CLI can emit a friendly install hint. */
export class MysqlUnavailableError extends Error {
constructor() {
super('mysql2 is not installed')
this.name = 'MysqlUnavailableError'
}
}
// CLI entry guard (see render.mjs for pathToFileURL rationale)
const { pathToFileURL } = await import('node:url')
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [envPath, ddlPath] = process.argv.slice(2)
if (!envPath || !ddlPath) {
console.error('usage: node lib/apply-ddl.mjs <envPath> <ddlPath>')
process.exit(2)
}
try {
await applyDDL({ envPath, ddlPath })
console.log(`apply-ddl: applied ${ddlPath} using ${envPath}`)
} catch (e) {
if (e instanceof MysqlUnavailableError) {
console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.')
process.exit(1)
}
console.error(`apply-ddl: failed — ${e?.message || e}`)
process.exit(1)
}
}