mysql-dev.js 6.53 KB
#!/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
`)
}