add migration system

This commit is contained in:
Maxime Duchêne-Savard 2026-07-30 14:32:24 -04:00
parent 0eb488be2c
commit 1ec9f7e8e7
6 changed files with 501 additions and 2 deletions

View File

@ -31,6 +31,20 @@
fields: EntityField[] fields: EntityField[]
} }
interface MigrationValidation {
valid: boolean
errors: string[]
warnings: string[]
actions: string[]
}
interface MigrationResult {
success: boolean
message: string
actions: string[]
errors: string[]
}
const entities = ref<EntityDefinition[]>([]) const entities = ref<EntityDefinition[]>([])
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
@ -47,6 +61,10 @@
const editingFieldType = ref<FieldType>('TEXT') const editingFieldType = ref<FieldType>('TEXT')
const editingTargetEntityId = ref<number>() const editingTargetEntityId = ref<number>()
const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE') const editingRelationshipType = ref<RelationshipType>('ONE_TO_ONE')
const validatingMigration = ref(false)
const runningMigration = ref(false)
const migrationValidation = ref<MigrationValidation>()
const migrationResult = ref<MigrationResult>()
function request (url: string, method = 'GET', values?: Record<string, string>) { function request (url: string, method = 'GET', values?: Record<string, string>) {
return fetch(url, { return fetch(url, {
@ -223,6 +241,8 @@
errorMessage.value = '' errorMessage.value = ''
try { try {
await action() await action()
migrationValidation.value = undefined
migrationResult.value = undefined
} catch { } catch {
errorMessage.value = 'The change could not be saved. Please try again.' errorMessage.value = 'The change could not be saved. Please try again.'
} finally { } finally {
@ -230,6 +250,45 @@
} }
} }
async function validateMigration () {
validatingMigration.value = true
migrationResult.value = undefined
errorMessage.value = ''
try {
const response = await request('/api/entity-definitions/migration/validate', 'POST')
if (!response.ok) throw new Error('Migration validation failed')
migrationValidation.value = await response.json() as MigrationValidation
} catch {
errorMessage.value = 'Unable to validate the entity migration.'
} finally {
validatingMigration.value = false
}
}
async function runMigration () {
if (!migrationValidation.value?.valid) return
runningMigration.value = true
migrationResult.value = undefined
errorMessage.value = ''
try {
const response = await request('/api/entity-definitions/migration/run', 'POST')
const result = await response.json() as MigrationResult
migrationResult.value = result
if (!response.ok && result.errors.length === 0) {
result.errors = ['The migration could not be completed.']
}
} catch {
migrationResult.value = {
success: false,
message: 'Entity migration failed. No database changes were kept.',
actions: [],
errors: ['The server did not return migration details.'],
}
} finally {
runningMigration.value = false
}
}
function typeLabel (type: FieldType) { function typeLabel (type: FieldType) {
if (type === 'RELATIONSHIP') return 'Relationship' if (type === 'RELATIONSHIP') return 'Relationship'
if (type === 'USER') return 'User reference' if (type === 'USER') return 'User reference'
@ -254,7 +313,7 @@
<header> <header>
<p class="eyebrow">Administration</p> <p class="eyebrow">Administration</p>
<h1>Entity Configuration</h1> <h1>Entity Configuration</h1>
<p>Define the entities and fields your CRM will use. This configuration does not create database tables yet.</p> <p>Define the entities and fields your CRM will use, then validate and apply the database changes.</p>
</header> </header>
<form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity"> <form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity">
@ -276,6 +335,74 @@
</form> </form>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
<section aria-labelledby="migration-heading" class="migration-panel">
<div>
<h2 id="migration-heading">Database migration</h2>
<p>Validate the configuration to preview the tables, fields, and relationships that will be created.</p>
</div>
<div class="migration-actions">
<button
class="secondary-button"
:disabled="saving || validatingMigration || runningMigration"
type="button"
@click="validateMigration"
>
{{ validatingMigration ? 'Validating…' : 'Validate migration' }}
</button>
<button
class="primary-button"
:disabled="!migrationValidation?.valid || validatingMigration || runningMigration"
type="button"
@click="runMigration"
>
{{ runningMigration ? 'Running migration…' : 'Run migration' }}
</button>
</div>
<div
v-if="migrationValidation"
class="migration-report"
:class="migrationValidation.valid ? 'valid' : 'invalid'"
role="status"
>
<strong>{{ migrationValidation.valid ? 'Validation passed' : 'Validation failed' }}</strong>
<ul v-if="migrationValidation.errors.length > 0" class="error-list">
<li v-for="item in migrationValidation.errors" :key="item">{{ item }}</li>
</ul>
<ul v-if="migrationValidation.warnings.length > 0" class="warning-list">
<li v-for="item in migrationValidation.warnings" :key="item">{{ item }}</li>
</ul>
<template v-if="migrationValidation.actions.length > 0">
<p>Planned changes</p>
<ol>
<li v-for="item in migrationValidation.actions" :key="item">{{ item }}</li>
</ol>
</template>
<p v-else-if="migrationValidation.valid">The database is already up to date.</p>
</div>
<div
v-if="migrationResult"
class="migration-report"
:class="migrationResult.success ? 'valid' : 'invalid'"
role="alert"
>
<strong>{{ migrationResult.message }}</strong>
<ul v-if="migrationResult.errors.length > 0" class="error-list">
<li v-for="item in migrationResult.errors" :key="item">{{ item }}</li>
</ul>
</div>
</section>
<p v-if="loading" class="status">Loading configuration</p> <p v-if="loading" class="status">Loading configuration</p>
<p v-else-if="entities.length === 0" class="status">No entities yet. Add your first entity above.</p> <p v-else-if="entities.length === 0" class="status">No entities yet. Add your first entity above.</p>
@ -437,6 +564,17 @@
.add-field { padding: 1rem 1.25rem 1.25rem; border-top: 1px solid var(--v0-divider); background: color-mix(in srgb, var(--v0-surface-variant) 35%, transparent); } .add-field { padding: 1rem 1.25rem 1.25rem; border-top: 1px solid var(--v0-divider); background: color-mix(in srgb, var(--v0-surface-variant) 35%, transparent); }
.field-inputs input { flex: 1; } .field-inputs input { flex: 1; }
.error { color: var(--v0-error); } .error { color: var(--v0-error); }
.migration-panel { margin-bottom: 2rem; padding: 1.25rem; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.migration-panel h2 { margin: 0; font-size: 1.15rem; }
.migration-panel > div > p { margin: 0.35rem 0 0; color: var(--v0-on-surface-variant); }
.migration-actions { display: flex; gap: 0.65rem; margin-top: 1rem; }
.migration-report { margin-top: 1rem; padding: 1rem; border-radius: 0.7rem; line-height: 1.5; }
.migration-report.valid { border: 1px solid #2e7d32; background: color-mix(in srgb, #2e7d32 10%, transparent); }
.migration-report.invalid { border: 1px solid var(--v0-error); background: color-mix(in srgb, var(--v0-error) 9%, transparent); }
.migration-report ul, .migration-report ol { margin: 0.55rem 0 0; padding-left: 1.3rem; }
.migration-report > p { margin: 0.65rem 0 0; font-weight: 600; }
.error-list { color: var(--v0-error); }
.warning-list { color: var(--v0-on-surface-variant); }
.status { padding: 1.5rem; border: 1px dashed var(--v0-divider); border-radius: 0.8rem; color: var(--v0-on-surface-variant); } .status { padding: 1.5rem; border: 1px dashed var(--v0-divider); border-radius: 0.8rem; color: var(--v0-on-surface-variant); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 700px) { @media (max-width: 700px) {
@ -444,5 +582,6 @@
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; } .form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
.entity-heading, .field-row { padding-block: 1rem; } .entity-heading, .field-row { padding-block: 1rem; }
input, select, .primary-button, .secondary-button { width: 100%; } input, select, .primary-button, .secondary-button { width: 100%; }
.migration-actions { align-items: stretch; flex-direction: column; }
} }
</style> </style>

View File

@ -168,3 +168,45 @@ test('admin can add, edit, and remove an entity and its fields', async ({ page }
await contact.getByRole('button', { name: 'Remove entity' }).click() await contact.getByRole('button', { name: 'Remove entity' }).click()
await expect(page.getByText('No entities yet.')).toBeVisible() await expect(page.getByText('No entities yet.')).toBeVisible()
}) })
test('admin validates before running a migration and sees migration failures', async ({ page }) => {
await useAdminSession(page)
await page.route('**/api/entity-definitions', route => route.fulfill({
json: [{
id: 1,
name: 'Company',
identifier: 'company',
fields: [{ id: 1, name: 'Name', identifier: 'name', type: 'TEXT' }],
}],
}))
await page.route('**/api/entity-definitions/migration/validate', route => route.fulfill({
json: {
valid: true,
errors: [],
warnings: ['Table company already exists; missing fields will be added with ALTER TABLE.'],
actions: ['Add company.name (TEXT).'],
},
}))
await page.route('**/api/entity-definitions/migration/run', route => route.fulfill({
status: 400,
json: {
success: false,
message: 'Entity migration failed and was rolled back.',
actions: [],
errors: ['permission denied for table company'],
},
}))
await page.goto('/admin/entities')
const runButton = page.getByRole('button', { name: 'Run migration' })
await expect(runButton).toBeDisabled()
await page.getByRole('button', { name: 'Validate migration' }).click()
await expect(page.getByText('Validation passed')).toBeVisible()
await expect(page.getByText('Add company.name (TEXT).')).toBeVisible()
await expect(runButton).toBeEnabled()
await runButton.click()
await expect(page.getByText('Entity migration failed and was rolled back.')).toBeVisible()
await expect(page.getByText('permission denied for table company')).toBeVisible()
})

View File

@ -1,4 +1,5 @@
import dev.mduchene.bolts.entity.EntityDefinitionRepository import dev.mduchene.bolts.entity.EntityDefinitionRepository
import dev.mduchene.bolts.entity.EntityMigrationService
import dev.mduchene.bolts.persistence.Database import dev.mduchene.bolts.persistence.Database
import dev.mduchene.bolts.persistence.DatabaseConfig import dev.mduchene.bolts.persistence.DatabaseConfig
import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.LoginService
@ -19,11 +20,12 @@ fun main() {
val loginService = LoginService(users, SessionRepository(database)) val loginService = LoginService(users, SessionRepository(database))
loginService.initializeAdmin() loginService.initializeAdmin()
val entities = EntityDefinitionRepository(database)
val controllers = listOf( val controllers = listOf(
HomeController(), HomeController(),
AuthController(loginService), AuthController(loginService),
UserController(users, loginService), UserController(users, loginService),
EntityDefinitionController(EntityDefinitionRepository(database), loginService), EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
) )
Javalin.create { config -> Javalin.create { config ->

View File

@ -0,0 +1,293 @@
package dev.mduchene.bolts.entity
import dev.mduchene.bolts.persistence.Database
import java.sql.Connection
data class EntityMigrationValidation(
val valid: Boolean,
val errors: List<String>,
val warnings: List<String>,
val actions: List<String>,
)
data class EntityMigrationResult(
val success: Boolean,
val message: String,
val actions: List<String>,
val errors: List<String> = emptyList(),
)
class EntityMigrationService(
private val database: Database,
private val entities: EntityDefinitionRepository,
) {
fun validate(): EntityMigrationValidation =
database.getConnection().use { connection -> plan(connection, entities.findAll()).validation }
fun migrate(): EntityMigrationResult =
try {
database.transaction { connection ->
val plan = plan(connection, loadEntities(connection))
if (!plan.validation.valid) {
return@transaction EntityMigrationResult(
success = false,
message = "Validation failed; no database changes were made.",
actions = plan.validation.actions,
errors = plan.validation.errors,
)
}
connection.createStatement().use { statement ->
plan.statements.forEach { statement.execute(it.sql) }
}
EntityMigrationResult(
success = true,
message = "Entity migration completed successfully.",
actions = plan.validation.actions,
)
}
} catch (exception: Exception) {
EntityMigrationResult(
success = false,
message = "Entity migration failed and was rolled back.",
actions = emptyList(),
errors = listOf(exception.message ?: "Database migration failed"),
)
}
private fun plan(connection: Connection, definitions: List<EntityDefinition>): MigrationPlan {
val errors = mutableListOf<String>()
val warnings = mutableListOf<String>()
val statements = mutableListOf<MigrationStatement>()
val identifierPattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
val reservedTables = setOf("entity_definitions", "entity_fields", "schema_migrations", "sessions", "users")
val duplicateEntities = definitions.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }
if (definitions.isEmpty()) warnings += "No entity definitions are configured."
duplicateEntities.values.forEach {
errors += "Entities ${it.joinToString { entity -> "“${entity.name}" }} use the same identifier."
}
definitions.forEach { entity ->
if (!identifierPattern.matches(entity.identifier)) {
errors += "Entity “${entity.name}” has an invalid SQL identifier: ${entity.identifier}."
}
if (entity.identifier.lowercase() in reservedTables) {
errors += "Entity “${entity.name}” uses the reserved application table ${entity.identifier}."
}
entity.fields.groupBy { it.identifier.lowercase() }.filterValues { it.size > 1 }.values.forEach {
errors += "Entity “${entity.name}” has duplicate field identifier ${it.first().identifier}."
}
entity.fields.forEach { field ->
if (!identifierPattern.matches(field.identifier)) {
errors += "Field “${entity.name}.${field.name}” has an invalid SQL identifier: ${field.identifier}."
}
if (field.identifier.equals("id", ignoreCase = true)) {
errors += "Field “${entity.name}.${field.name}” cannot use the reserved identifier id."
}
if (field.type == FieldType.RELATIONSHIP) {
if (field.targetEntityId == null || definitions.none { it.id == field.targetEntityId }) {
errors += "Relationship “${entity.name}.${field.name}” has no valid target entity."
}
if (field.relationshipType == null) {
errors += "Relationship “${entity.name}.${field.name}” has no relationship type."
}
}
}
}
if (errors.isNotEmpty()) return migrationPlan(errors, warnings, statements)
val existingTables = tableNames(connection)
val ordered = dependencyOrder(definitions)
ordered.forEach { entity ->
if (entity.identifier.lowercase() !in existingTables) {
statements += MigrationStatement(
"""CREATE TABLE ${quote(entity.identifier)} ("id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY)""",
"Create table ${entity.identifier}.",
)
} else {
warnings += "Table ${entity.identifier} already exists; missing fields will be added with ALTER TABLE."
val idType = columnTypes(connection, entity.identifier)["id"]
if (idType == null) errors += "Existing table ${entity.identifier} has no id column."
else if (idType !in setOf("bigint", "integer", "smallint")) {
errors += "Existing table ${entity.identifier} has an incompatible id column ($idType)."
}
}
}
definitions.forEach { entity ->
val columns = if (entity.identifier.lowercase() in existingTables) {
columnTypes(connection, entity.identifier)
} else {
emptyMap()
}
entity.fields.forEach { field ->
val expected = sqlType(field.type)
val actual = columns[field.identifier.lowercase()]
if (actual == null) {
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
statements += MigrationStatement(
"ALTER TABLE ${quote(table)} ADD COLUMN ${quote(field.identifier)} $expected",
"Add ${entity.identifier}.${field.identifier} ($expected).",
)
} else if (!compatible(field.type, actual)) {
errors += "Existing column ${entity.identifier}.${field.identifier} is $actual, but ${field.type} requires $expected."
} else {
warnings += "Column ${entity.identifier}.${field.identifier} already exists and will be left unchanged."
}
}
}
definitions.forEach { entity ->
entity.fields.filter { it.type == FieldType.RELATIONSHIP || it.type == FieldType.USER }.forEach { field ->
val target = if (field.type == FieldType.USER) "users" else {
definitions.first { it.id == field.targetEntityId }.identifier.let {
existingTables[it.lowercase()] ?: it
}
}
val table = existingTables[entity.identifier.lowercase()] ?: entity.identifier
val constraint = "em_fk_${entity.id}_${field.id}"
if (!constraintExists(connection, constraint)) {
statements += MigrationStatement(
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(constraint)} " +
"FOREIGN KEY (${quote(field.identifier)}) REFERENCES ${quote(target)} (\"id\")",
"Add reference ${entity.identifier}.${field.identifier}$target.id.",
)
}
if (field.relationshipType == RelationshipType.ONE_TO_ONE) {
val unique = "em_uq_${entity.id}_${field.id}"
if (!constraintExists(connection, unique)) {
statements += MigrationStatement(
"ALTER TABLE ${quote(table)} ADD CONSTRAINT ${quote(unique)} " +
"UNIQUE (${quote(field.identifier)})",
"Make ${entity.identifier}.${field.identifier} one-to-one.",
)
}
}
}
}
return migrationPlan(errors, warnings, statements)
}
private fun loadEntities(connection: Connection): List<EntityDefinition> {
val fields = mutableMapOf<Long, MutableList<EntityField>>()
connection.prepareStatement(
"""
SELECT id, entity_id, name, identifier, field_type, target_entity_id, relationship_type
FROM entity_fields ORDER BY id
""".trimIndent(),
).use { statement ->
statement.executeQuery().use { result ->
while (result.next()) {
val entityId = result.getLong("entity_id")
fields.getOrPut(entityId, ::mutableListOf) += EntityField(
id = result.getLong("id"),
name = result.getString("name"),
identifier = result.getString("identifier"),
type = FieldType.valueOf(result.getString("field_type")),
targetEntityId = result.getLong("target_entity_id").takeUnless { result.wasNull() },
relationshipType = result.getString("relationship_type")?.let(RelationshipType::valueOf),
)
}
}
}
return connection.prepareStatement("SELECT id, name, identifier FROM entity_definitions ORDER BY id").use { statement ->
statement.executeQuery().use { result ->
buildList {
while (result.next()) {
val id = result.getLong("id")
add(EntityDefinition(id, result.getString("name"), result.getString("identifier"), fields[id].orEmpty()))
}
}
}
}
}
private fun dependencyOrder(definitions: List<EntityDefinition>): List<EntityDefinition> {
val byId = definitions.associateBy(EntityDefinition::id)
val visited = mutableSetOf<Long>()
val visiting = mutableSetOf<Long>()
val result = mutableListOf<EntityDefinition>()
fun visit(entity: EntityDefinition) {
if (entity.id in visited || !visiting.add(entity.id)) return
entity.fields.mapNotNull { it.targetEntityId }.mapNotNull(byId::get).forEach(::visit)
visiting.remove(entity.id)
visited += entity.id
result += entity
}
definitions.forEach(::visit)
return result
}
private fun tableNames(connection: Connection): Map<String, String> =
connection.prepareStatement(
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()",
).use { statement ->
statement.executeQuery().use { result ->
buildMap {
while (result.next()) {
val name = result.getString(1)
put(name.lowercase(), name)
}
}
}
}
private fun columnTypes(connection: Connection, table: String): Map<String, String> =
connection.prepareStatement(
"""
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema = current_schema() AND lower(table_name) = lower(?)
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.executeQuery().use { result ->
buildMap { while (result.next()) put(result.getString(1).lowercase(), result.getString(2).lowercase()) }
}
}
private fun constraintExists(connection: Connection, name: String): Boolean =
connection.prepareStatement(
"SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name = ?",
).use { statement ->
statement.setString(1, name)
statement.executeQuery().use { it.next() }
}
private fun sqlType(type: FieldType): String = when (type) {
FieldType.TEXT -> "TEXT"
FieldType.NUMBER -> "NUMERIC"
FieldType.BOOLEAN -> "BOOLEAN"
FieldType.DATE -> "DATE"
FieldType.EMAIL, FieldType.PHONE -> "VARCHAR(255)"
FieldType.RELATIONSHIP, FieldType.USER -> "BIGINT"
}
private fun compatible(type: FieldType, actual: String): Boolean = when (type) {
FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE -> actual in setOf("text", "character varying", "character")
FieldType.NUMBER -> actual in setOf("numeric", "decimal", "real", "double precision", "bigint", "integer", "smallint")
FieldType.BOOLEAN -> actual == "boolean"
FieldType.DATE -> actual == "date"
FieldType.RELATIONSHIP, FieldType.USER -> actual in setOf("bigint", "integer", "smallint")
}
private fun quote(identifier: String) = "\"${identifier.replace("\"", "\"\"")}\""
private fun migrationPlan(
errors: List<String>,
warnings: List<String>,
statements: List<MigrationStatement>,
) = MigrationPlan(
EntityMigrationValidation(errors.isEmpty(), errors, warnings, statements.map(MigrationStatement::description)),
statements,
)
private data class MigrationStatement(val sql: String, val description: String)
private data class MigrationPlan(
val validation: EntityMigrationValidation,
val statements: List<MigrationStatement>,
)
}

View File

@ -45,6 +45,17 @@ class Database(
} }
} }
fun <T> transaction(action: (Connection) -> T): T =
getConnection().use { connection ->
connection.autoCommit = false
try {
action(connection).also { connection.commit() }
} catch (exception: Exception) {
connection.rollback()
throw exception
}
}
private fun <T> query( private fun <T> query(
sql: String, sql: String,
bind: PreparedStatement.() -> Unit, bind: PreparedStatement.() -> Unit,

View File

@ -1,6 +1,7 @@
package dev.mduchene.bolts.web package dev.mduchene.bolts.web
import dev.mduchene.bolts.entity.EntityDefinitionRepository import dev.mduchene.bolts.entity.EntityDefinitionRepository
import dev.mduchene.bolts.entity.EntityMigrationService
import dev.mduchene.bolts.entity.FieldType import dev.mduchene.bolts.entity.FieldType
import dev.mduchene.bolts.entity.RelationshipType import dev.mduchene.bolts.entity.RelationshipType
import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.LoginService
@ -11,12 +12,23 @@ import io.javalin.router.JavalinDefaultRoutingApi
class EntityDefinitionController( class EntityDefinitionController(
private val entities: EntityDefinitionRepository, private val entities: EntityDefinitionRepository,
private val loginService: LoginService, private val loginService: LoginService,
private val migrations: EntityMigrationService,
) : Controller { ) : Controller {
override fun register(routes: JavalinDefaultRoutingApi) { override fun register(routes: JavalinDefaultRoutingApi) {
routes.get("/api/entity-definitions") { ctx -> routes.get("/api/entity-definitions") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get if (!ctx.requireAdmin(loginService)) return@get
ctx.json(entities.findAll()) ctx.json(entities.findAll())
} }
routes.post("/api/entity-definitions/migration/validate") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
ctx.json(migrations.validate())
}
routes.post("/api/entity-definitions/migration/run") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
val result = migrations.migrate()
if (!result.success) ctx.status(HttpStatus.BAD_REQUEST)
ctx.json(result)
}
routes.post("/api/entity-definitions") { ctx -> routes.post("/api/entity-definitions") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post if (!ctx.requireAdmin(loginService)) return@post
val name = ctx.requiredFormParam("name") ?: return@post val name = ctx.requiredFormParam("name") ?: return@post