feature: add db:create command (#8760)

This commit is contained in:
Harminder Virk
2024-08-26 15:04:52 +05:30
committed by GitHub
parent cd4063de74
commit 7c2cfc132a
9 changed files with 396 additions and 1 deletions

View File

@@ -125,6 +125,28 @@ function buildLocalCommands(cli, isLocalProject) {
desc: `Create a new Medusa project.`,
handler: handlerP(newStarter),
})
.command({
command: "db:create",
desc: "Create the database used by your application",
builder: (builder) => {
builder.option("db", {
type: "string",
describe: "Specify the name of the database you want to create",
})
builder.option("interactive", {
type: "boolean",
default: true,
describe:
"Display prompts. Use --no-interactive flag to run the command without prompts",
})
},
handler: handlerP(
getCommandHandler("db/create", (args, cmd) => {
process.env.NODE_ENV = process.env.NODE_ENV || `development`
return cmd(args)
})
),
})
.command({
command: `telemetry`,
describe: `Enable or disable collection of anonymous usage data.`,

View File

@@ -40,6 +40,8 @@
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pg-connection-string": "^2.6.4",
"pluralize": "^8.0.0",
"ulid": "^2.3.0"
},

View File

@@ -51,6 +51,26 @@ export class EnvEditor {
)
}
/**
* Returns the value for an existing key from the
* ".env" file
*/
get(key: string): string | null {
const envFile = this.#files.find((file) => file.filePath.endsWith(".env"))
if (!envFile) {
return null
}
const matchingLine = envFile.contents.find((line) =>
line.startsWith(`${key}=`)
)
if (!matchingLine) {
return null
}
const [_, ...rest] = matchingLine.split("=")
return rest.join("=")
}
/**
* Set key-value pair to the dot-env files.
*

View File

@@ -26,5 +26,6 @@ export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./pg"
export const MedusaModuleType = Symbol.for("MedusaModule")

View File

@@ -0,0 +1,52 @@
/*
|--------------------------------------------------------------------------
| PostgreSQL utilities
|--------------------------------------------------------------------------
|
| @note
| These utlities does not rely on an MedusaJS application and neither
| uses the Mikro ORM.
| The goal here is to run DB operations without booting the application.
|
| For example:
| Creating a database from CLI, or checking if a database exists
|
*/
import { Client, type ClientConfig } from "pg"
import { parse } from "pg-connection-string"
/**
* Parsers the database connection string into an object
* of postgreSQL options
*/
export function parseConnectionString(connectionString: string) {
return parse(connectionString)
}
/**
* Creates a PostgreSQL database client using the connection
* string or database options
*/
export function createClient(options: string | ClientConfig) {
return new Client(options)
}
/**
* Creates a database using the client. Make sure to call
* `client.connect` before using this utility.
*/
export async function createDb(client: Client, databaseName: string) {
await client.query(`CREATE DATABASE "${databaseName}"`)
}
/**
* Checks if a database exists using the Client. Make sure to call
* `client.connect` before using this utility.
*/
export async function dbExists(client: Client, databaseName: string) {
const result = await client.query(
`SELECT datname FROM pg_catalog.pg_database WHERE datname='${databaseName}';`
)
return !!result.rowCount
}

View File

@@ -0,0 +1,118 @@
import { dropDatabase } from "pg-god"
import {
createClient,
parseConnectionString,
dbExists,
createDb,
} from "../../index"
const DB_HOST = process.env.DB_HOST ?? "localhost"
const DB_USERNAME = process.env.DB_USERNAME ?? ""
const DB_PASSWORD = process.env.DB_PASSWORD ?? ""
export const pgGodCredentials = {
user: DB_USERNAME,
password: DB_PASSWORD,
host: DB_HOST,
}
describe("Pg | createClient", () => {
test("create client connection from connection options", async () => {
const client = createClient(pgGodCredentials)
await client.connect()
await client.end()
})
test("create client connection from connectionString without db name", async () => {
const client = createClient(
`postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}`
)
await client.connect()
await client.end()
})
test("create client connection from connectionString with db name", async () => {
const client = createClient(
`postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo`
)
await expect(() => client.connect()).rejects.toMatchInlineSnapshot(
`[error: database "foo" does not exist]`
)
})
})
describe("Pg | parseConnectionString", () => {
test("parse connection string without db name", async () => {
const options = parseConnectionString(
`postgres://${DB_USERNAME}:@${DB_HOST}`
)
expect(options).toEqual({
user: DB_USERNAME,
password: "",
host: DB_HOST,
port: "",
database: null,
})
})
test("parse connection string with db name", async () => {
const options = parseConnectionString(
`postgres://${DB_USERNAME}:@${DB_HOST}/foo`
)
expect(options).toEqual({
user: DB_USERNAME,
password: "",
host: DB_HOST,
port: "",
database: "foo",
})
})
})
describe("Pg | dbExists", () => {
beforeEach(async () => {
await dropDatabase({ databaseName: "foo" }, pgGodCredentials)
})
afterAll(async () => {
await dropDatabase({ databaseName: "foo" }, pgGodCredentials)
})
test("return false when db does not exist", async () => {
const options = parseConnectionString(
`postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo`
)
const client = createClient({
host: options.host!,
port: options.port ? Number(options.port) : undefined,
user: options.user,
password: options.password,
})
await client.connect()
const exists = await dbExists(client, options.database!)
await client.end()
expect(exists).toBe(false)
})
test("return true when db exist", async () => {
const options = parseConnectionString(
`postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo`
)
const client = createClient({
host: options.host!,
port: options.port ? Number(options.port) : undefined,
user: options.user,
password: options.password,
})
await client.connect()
await createDb(client, options.database!)
const exists = await dbExists(client, options.database!)
await client.end()
expect(exists).toBe(true)
})
})

View File

@@ -46,6 +46,7 @@
},
"dependencies": {
"@inquirer/checkbox": "^2.3.11",
"@inquirer/input": "^2.2.9",
"@medusajs/admin-sdk": "0.0.1",
"@medusajs/core-flows": "^0.0.9",
"@medusajs/framework": "^0.0.1",
@@ -75,6 +76,7 @@
"node-schedule": "^2.1.1",
"qs": "^6.11.2",
"request-ip": "^3.3.0",
"slugify": "^1.6.6",
"uuid": "^9.0.0",
"zod": "3.22.4"
},

View File

@@ -0,0 +1,111 @@
import slugify from "slugify"
import { basename } from "path"
import input from "@inquirer/input"
import { logger } from "@medusajs/framework"
import {
createDb,
dbExists,
EnvEditor,
createClient,
parseConnectionString,
} from "@medusajs/utils"
const main = async function ({ directory, interactive, db }) {
let dbName = db
/**
* Loading the ".env" file in editor mode so that
* we can read values from it and update its
* contents.
*/
const envEditor = new EnvEditor(directory)
await envEditor.load()
/**
* Ensure the "DATABASE_URL" is defined before we attempt to
* create the database.
*
* Also we will discard the database name from the connection
* string because the mentioned database might not exist
*/
const dbConnectionString = envEditor.get("DATABASE_URL")
if (!dbConnectionString) {
logger.error(
`Missing "DATABASE_URL" inside the .env file. The value is required to connect to the PostgreSQL server`
)
process.exitCode = 1
return
}
/**
* Use default value + prompt only when the dbName is not
* provided via a flag
*/
if (!dbName) {
const defaultValue =
envEditor.get("DB_NAME") ?? `medusa-${slugify(basename(directory))}`
if (interactive) {
try {
dbName = await input({
message: "Enter the database name",
default: defaultValue,
required: true,
})
} catch (error) {
if (error.name === "ExitPromptError") {
process.exit()
}
throw error
}
} else {
dbName = defaultValue
}
}
/**
* Parse connection string specified as "DATABASE_URL" inside the
* .env file and create a client instance from it.
*
* Remember we should not specify the database name from the
* connection string, because this database might not
* exist
*/
const connectionOptions = parseConnectionString(dbConnectionString)
const client = createClient({
host: connectionOptions.host!,
port: connectionOptions.port ? Number(connectionOptions.port) : undefined,
user: connectionOptions.user,
password: connectionOptions.password,
})
try {
await client.connect()
logger.info(`Connection established with the database "${dbName}"`)
} catch (error) {
process.exitCode = 1
logger.error(
"Unable to establish database connection because of the following error"
)
logger.error(error)
return
}
if (await dbExists(client, dbName)) {
logger.info(`Database "${dbName}" already exists`)
envEditor.set("DB_NAME", dbName)
await envEditor.save()
logger.info(`Updated .env file with "DB_NAME=${dbName}"`)
return
}
await createDb(client, dbName)
logger.info(`Created database "${dbName}"`)
envEditor.set("DB_NAME", dbName)
await envEditor.save()
logger.info(`Updated .env file with "DB_NAME=${dbName}"`)
}
export default main

View File

@@ -3669,6 +3669,27 @@ __metadata:
languageName: node
linkType: hard
"@inquirer/core@npm:^9.0.10":
version: 9.0.10
resolution: "@inquirer/core@npm:9.0.10"
dependencies:
"@inquirer/figures": ^1.0.5
"@inquirer/type": ^1.5.2
"@types/mute-stream": ^0.0.4
"@types/node": ^22.1.0
"@types/wrap-ansi": ^3.0.0
ansi-escapes: ^4.3.2
cli-spinners: ^2.9.2
cli-width: ^4.1.0
mute-stream: ^1.0.0
signal-exit: ^4.1.0
strip-ansi: ^6.0.1
wrap-ansi: ^6.2.0
yoctocolors-cjs: ^2.1.2
checksum: 117f50a55b5ebee8bfc62ea6adec87035f28ee7ace1efea67895c3d32ab50bf569ecd3ca33c457d0c7ae4240b9fe4d7b698ab70946ac561ab579d0920ddc98bb
languageName: node
linkType: hard
"@inquirer/core@npm:^9.0.3":
version: 9.0.3
resolution: "@inquirer/core@npm:9.0.3"
@@ -3704,6 +3725,23 @@ __metadata:
languageName: node
linkType: hard
"@inquirer/figures@npm:^1.0.5":
version: 1.0.5
resolution: "@inquirer/figures@npm:1.0.5"
checksum: ec9ba23db42cb33fa18eb919abf2a18e750e739e64c1883ce4a98345cd5711c60cac12d1faf56a859f52d387deb221c8d3dfe60344ee07955a9a262f8b821fe3
languageName: node
linkType: hard
"@inquirer/input@npm:^2.2.9":
version: 2.2.9
resolution: "@inquirer/input@npm:2.2.9"
dependencies:
"@inquirer/core": ^9.0.10
"@inquirer/type": ^1.5.2
checksum: 0fcdc5d9c17712f9a2c79f39d1e03ed4a58cfe1dd1095209b4c83621dba2cb94db03b7df0df34e22584bce9e53403a284c76721def66a452e4751666d945d99f
languageName: node
linkType: hard
"@inquirer/type@npm:^1.3.1":
version: 1.3.1
resolution: "@inquirer/type@npm:1.3.1"
@@ -3720,6 +3758,15 @@ __metadata:
languageName: node
linkType: hard
"@inquirer/type@npm:^1.5.2":
version: 1.5.2
resolution: "@inquirer/type@npm:1.5.2"
dependencies:
mute-stream: ^1.0.0
checksum: e2c91562c07440620bd805a60438b78c188d2727d86f396a68c480e4357469a72cd80bd2c158faa6b987671911566bd4fc12976f4bdda1a3441594e318c40058
languageName: node
linkType: hard
"@internationalized/date@npm:^3.5.4":
version: 3.5.4
resolution: "@internationalized/date@npm:3.5.4"
@@ -4860,6 +4907,7 @@ __metadata:
resolution: "@medusajs/medusa@workspace:packages/medusa"
dependencies:
"@inquirer/checkbox": ^2.3.11
"@inquirer/input": ^2.2.9
"@medusajs/admin-sdk": 0.0.1
"@medusajs/core-flows": ^0.0.9
"@medusajs/framework": ^0.0.1
@@ -4899,6 +4947,7 @@ __metadata:
qs: ^6.11.2
request-ip: ^3.3.0
rimraf: ^5.0.1
slugify: ^1.6.6
supertest: ^4.0.2
typescript: ^5.3.3
uuid: ^9.0.0
@@ -5423,6 +5472,8 @@ __metadata:
express: ^4.18.2
jest: ^29.7.0
jsonwebtoken: ^9.0.2
pg: ^8.12.0
pg-connection-string: ^2.6.4
pluralize: ^8.0.0
rimraf: ^5.0.1
ts-jest: ^29.1.1
@@ -12160,6 +12211,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^22.1.0":
version: 22.5.0
resolution: "@types/node@npm:22.5.0"
dependencies:
undici-types: ~6.19.2
checksum: 45aa75c5e71645fac42dced4eff7f197c3fdfff6e8a9fdacd0eb2e748ff21ee70ffb73982f068a58e8d73b2c088a63613142c125236cdcf3c072ea97eada1559
languageName: node
linkType: hard
"@types/normalize-package-data@npm:^2.4.0":
version: 2.4.4
resolution: "@types/normalize-package-data@npm:2.4.4"
@@ -24557,7 +24617,7 @@ __metadata:
languageName: node
linkType: hard
"pg-connection-string@npm:^2.6.2":
"pg-connection-string@npm:^2.6.2, pg-connection-string@npm:^2.6.4":
version: 2.6.4
resolution: "pg-connection-string@npm:2.6.4"
checksum: 0d0b617df0fc6507bf6a94bdcd56c7a305788a1402d69bff9773350947c8f525d6d8136128065370749a3325e99658ae40fbdcce620fb8e60126181f0591a6a6
@@ -30181,6 +30241,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.19.2":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"
checksum: 078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344
languageName: node
linkType: hard
"unicode-canonical-property-names-ecmascript@npm:^2.0.0":
version: 2.0.0
resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"