apply-ddl.mjs
5.5 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
// lib/apply-ddl.mjs
//
// Replaces the inline `set -a; . .env.local; mysql < V1.sql` bash from db-init.
//
// parseEnv(): dotenv-style line parser. Pure parsing, NO variable expansion and
// NO shell execution — `$VAR`, backticks, `$(...)` and other shell constructs are
// kept verbatim as literal characters, which eliminates the shell-injection vector
// of `source`-ing an untrusted .env file.
//
// applyDDL(): connects with mysql2/promise (multipleStatements) to run a DDL file.
/**
* 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.
*
* Reads connection settings from the parsed env file. Recognised keys (with
* common aliases) — DB_HOST/MYSQL_HOST, DB_PORT/MYSQL_PORT, DB_USER/MYSQL_USER,
* DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_SCHEMA/DB_NAME/MYSQL_DATABASE.
* DB_SCHEMA is the plugin's canonical schema key (see .env.local template); the
* target database MUST resolve to a non-empty value or applyDDL fails closed —
* V1 DDL contains no `USE`/`CREATE DATABASE`, so a missing schema would only
* surface as an opaque `ER_NO_DB_ERROR` on the first CREATE TABLE.
*
* @param {{envPath: string, ddlPath: string}} opts
* @returns {Promise<void>}
*/
export async function applyDDL({ envPath, ddlPath }) {
const { readFileSync } = await import('node:fs')
let mysql
try {
;({ default: mysql } = await import('mysql2/promise'))
} catch {
throw new MysqlUnavailableError()
}
const env = parseEnv(readFileSync(envPath, 'utf8'))
const ddl = readFileSync(ddlPath, 'utf8')
const { host, port, user, password, database } = resolveDbConfig(env, envPath)
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.
*
* fail-closed on missing schema: DB_SCHEMA is the plugin's canonical key; V1 DDL
* has no `USE`/`CREATE DATABASE`, so a missing database would otherwise surface
* only as an opaque ER_NO_DB_ERROR on the first CREATE TABLE.
*
* @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)`)
}
return { host, port, user, password, database }
}
/** 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: node lib/apply-ddl.mjs <envPath> <ddlPath>
// Use pathToFileURL so the guard matches even when the path contains spaces or
// other characters that get percent-encoded in import.meta.url.
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)
}
}