mysql-dev.js
6.53 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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/usr/bin/env node
import fs from 'node:fs'
import net from 'node:net'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { devMysqlUrl } from '@drama/db'
const DEFAULT_DATABASE_URL = devMysqlUrl('voko')
const DEFAULT_ENV_FILE = 'apps/voko-server/.env'
const DEFAULT_DATABASE = 'voko'
const requireFromDb = createRequire(new URL('../../packages/db/package.json', import.meta.url))
const mysql = requireFromDb('mysql2/promise')
const args = parseArgs(process.argv.slice(2))
const command = args._[0] || 'help'
try {
if (command === 'url') {
const url = resolveBaseUrl(args)
const derived = deriveDatabaseUrl(url, args.database || DEFAULT_DATABASE)
console.log(args.raw ? derived : redactUrl(derived))
} else if (command === 'ensure') {
const url = resolveBaseUrl(args)
const database = args.database || databaseName(url)
await ensureDatabase({ url, database })
} else if (command === 'up') {
await up(args)
} else if (command === 'status') {
await status(args)
} else if (command === 'stop') {
runDocker(['compose', 'stop', 'mysql'], composeEnv(resolveBaseUrl(args)))
} else {
printHelp()
}
} catch (err) {
console.error(`[mysql-dev] ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
}
function parseArgs(argv) {
const out = { _: [] }
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i]
if (!item.startsWith('--')) {
out._.push(item)
continue
}
const key = item.slice(2)
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
out[key] = true
continue
}
out[key] = next
i += 1
}
return out
}
function resolveBaseUrl(args_) {
return String(args_.url || readEnvDatabaseUrl(args_.env || DEFAULT_ENV_FILE) || DEFAULT_DATABASE_URL)
}
function readEnvDatabaseUrl(file) {
if (!fs.existsSync(file)) return null
const text = fs.readFileSync(file, 'utf8')
const match = text.match(/^DATABASE_URL=['"]?([^'"\n]+)/m)
return match ? match[1] : null
}
function databaseName(rawUrl) {
const url = new URL(rawUrl)
const name = url.pathname.replace(/^\//, '')
if (!name) throw new Error(`DATABASE_URL missing database name: ${redactUrl(rawUrl)}`)
return name
}
function deriveDatabaseUrl(rawUrl, database) {
assertDatabaseName(database)
const url = new URL(rawUrl)
url.pathname = `/${database}`
return url.toString()
}
async function ensureDatabase({ url, database }) {
assertMysqlUrl(url)
assertDatabaseName(database)
const parsed = new URL(url)
const conn = await mysql.createConnection({
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 3306,
user: decodeURIComponent(parsed.username || ''),
password: decodeURIComponent(parsed.password || ''),
})
try {
const [rows] = await conn.query('SHOW DATABASES LIKE ?', [database])
if (Array.isArray(rows) && rows.length > 0) {
console.log(`[mysql-dev] database exists: ${database}`)
return
}
await conn.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`)
console.log(`[mysql-dev] database created: ${database}`)
} finally {
await conn.end()
}
}
async function up(args_) {
const url = resolveBaseUrl(args_)
const database = args_.database || databaseName(url)
if (await canEnsure(url, database)) return
if (await isPortOpen(url)) {
throw new Error(`MySQL port is already open but credentials failed: ${redactUrl(url)}`)
}
runDocker(['compose', 'up', '-d', 'mysql'], composeEnv(url, database))
await waitUntilReady(url, database)
}
/**
* docker-compose.yml 里 mysql 服务的口令 / 端口 / 初始库全部从这条 url 派生 ——
* 连库的一方和建库的一方读同一个值,不会分叉。
* @param {string} rawUrl
* @param {string} [database]
*/
function composeEnv(rawUrl, database) {
const parsed = new URL(rawUrl)
return {
MYSQL_ROOT_PASSWORD: decodeURIComponent(parsed.password || ''),
MYSQL_PORT: parsed.port || '3306',
MYSQL_DATABASE: database || databaseName(rawUrl),
}
}
async function status(args_) {
const url = resolveBaseUrl(args_)
const open = await isPortOpen(url)
console.log(`[mysql-dev] ${redactUrl(url)} ${open ? 'listening' : 'not-listening'}`)
}
async function canEnsure(url, database) {
try {
await ensureDatabase({ url, database })
return true
} catch {
return false
}
}
async function waitUntilReady(url, database) {
let lastErr = null
for (let i = 0; i < 30; i += 1) {
try {
await ensureDatabase({ url, database })
return
} catch (err) {
lastErr = err
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
throw lastErr || new Error('MySQL did not become ready')
}
/** @param {string[]} args_ @param {Record<string,string>} [composeEnv] compose 文件里 ${VAR} 的取值 */
function runDocker(args_, composeEnv = {}) {
const res = spawnSync('docker', args_, { stdio: 'inherit', env: { ...process.env, ...composeEnv } })
if (res.status !== 0) throw new Error(`docker ${args_.join(' ')} failed`)
}
function isPortOpen(rawUrl) {
const url = new URL(rawUrl)
const port = url.port ? Number(url.port) : 3306
return new Promise((resolve) => {
const socket = net.createConnection({ host: url.hostname, port })
socket.setTimeout(800)
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('timeout', () => {
socket.destroy()
resolve(false)
})
socket.once('error', () => resolve(false))
})
}
function assertMysqlUrl(rawUrl) {
const scheme = new URL(rawUrl).protocol.replace(/:$/, '')
if (scheme !== 'mysql' && scheme !== 'mysql2') {
throw new Error(`Only mysql/mysql2 DATABASE_URL is supported for local dev: ${scheme}`)
}
}
function assertDatabaseName(name) {
if (!/^[A-Za-z0-9_]+$/.test(String(name || ''))) {
throw new Error(`Invalid database name: ${name}`)
}
}
function redactUrl(rawUrl) {
try {
const url = new URL(rawUrl)
const user = url.username ? '<user>:***@' : ''
const port = url.port ? `:${url.port}` : ''
return `${url.protocol}//${user}${url.hostname}${port}${url.pathname}`
} catch {
return '<redacted>'
}
}
function printHelp() {
console.log(`Usage:
node tools/scripts/mysql-dev.js up [--url DATABASE_URL]
node tools/scripts/mysql-dev.js ensure [--url DATABASE_URL] [--database NAME]
node tools/scripts/mysql-dev.js url [--url DATABASE_URL] [--database NAME] [--raw]
node tools/scripts/mysql-dev.js status [--url DATABASE_URL]
node tools/scripts/mysql-dev.js stop
`)
}