implement migration

This commit is contained in:
2025-08-16 21:17:55 +02:00
parent 70000c93e2
commit 9c363d4f4c
15 changed files with 129 additions and 123 deletions

View File

@@ -30,8 +30,6 @@ dependencies {
implementation(libs.exposed.dao) implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc) implementation(libs.exposed.jdbc)
implementation(libs.postgres) implementation(libs.postgres)
implementation(libs.flyway)
implementation(libs.flyway.postgres)
implementation(libs.koin) implementation(libs.koin)
implementation(libs.koin.logger) implementation(libs.koin.logger)
implementation(libs.random) implementation(libs.random)

View File

@@ -1,9 +1,8 @@
[versions] [versions]
kotlin = "2.1.21" kotlin = "2.2.10"
ktor = "3.1.3" ktor = "3.1.3"
exposed = "0.60.0" exposed = "0.60.0"
postgres = "42.7.5" postgres = "42.7.5"
flyway = "11.7.0"
koin = "4.0.3" koin = "4.0.3"
random = "1.0.0" random = "1.0.0"
otp = "1.1.1" otp = "1.1.1"
@@ -32,8 +31,6 @@ exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "expose
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }
flyway = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-postgres = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
koin = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } koin = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

10
backend/gradlew vendored
View File

@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -112,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -203,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
@@ -211,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

6
backend/gradlew.bat vendored
View File

@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@@ -68,11 +70,11 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell

View File

@@ -1,7 +1,6 @@
package com.infendro.account package com.infendro.account
import com.infendro.account.config.* import com.infendro.account.config.*
import com.infendro.account.model.initializeDatabase
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.cio.* import io.ktor.server.cio.*
@@ -10,13 +9,14 @@ fun main(
) = EngineMain.main(args) ) = EngineMain.main(args)
fun Application.module() { fun Application.module() {
configureDatabase()
configureDependencyInjection() configureDependencyInjection()
configureDatabase()
configureSecurity() configureSecurity()
configureRouting()
configureSerialization() configureSerialization()
configureValidation() configureValidation()
configureRouting()
configureException() configureException()
initializeDatabase()
} }

View File

@@ -1,7 +1,7 @@
package com.infendro.account.config package com.infendro.account.config
import com.infendro.account.model.migrate
import io.ktor.server.application.Application import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
fun Application.configureDatabase() { fun Application.configureDatabase() {
@@ -12,9 +12,5 @@ fun Application.configureDatabase() {
user = config.username, user = config.username,
password = config.password, password = config.password,
) )
migrate()
val flyway = Flyway.configure()
.dataSource(config.url, config.username, config.password)
.load()
flyway.migrate()
} }

View File

@@ -2,7 +2,7 @@ package com.infendro.account.config
import com.infendro.account.model.repository.AccessRepository import com.infendro.account.model.repository.AccessRepository
import com.infendro.account.model.repository.AccountRepository import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.InitializationRepository import com.infendro.account.model.repository.MigrationRepository
import com.infendro.account.model.repository.SessionRepository import com.infendro.account.model.repository.SessionRepository
import com.infendro.account.service.AccessService import com.infendro.account.service.AccessService
import com.infendro.account.service.AccountService import com.infendro.account.service.AccountService
@@ -22,7 +22,7 @@ fun Application.configureDependencyInjection() {
} }
private val module = module { private val module = module {
singleOf(::InitializationRepository) singleOf(::MigrationRepository)
singleOf(::AccessRepository) singleOf(::AccessRepository)
singleOf(::AccountRepository) singleOf(::AccountRepository)
singleOf(::SessionRepository) singleOf(::SessionRepository)

View File

@@ -1,44 +0,0 @@
package com.infendro.account.model
import com.infendro.account.model.entity.InitializationTable
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.InitializationRepository
import com.infendro.account.util.SecureHasher
import io.ktor.server.application.Application
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.koin.ktor.ext.inject
fun Application.initializeDatabase() {
owner()
}
private fun Application.owner() = initialize("owner") {
val accountRepository by inject<AccountRepository>()
accountRepository.insert {
val salt = SecureHasher.generateSalt()
this.username = "infendro"
this.passwordHash = SecureHasher.hash("password", salt)
this.passwordSalt = salt
this.secret = "deadbeef"
this.role = Role.OWNER
}
}
private fun Application.initialize(
name: String,
block: () -> Unit,
) {
val initializationRepository by inject<InitializationRepository>()
initializationRepository
.singleOrNull { InitializationTable.name eq name }
?.run { return }
block()
initializationRepository.insert {
this.name = name
}
}

View File

@@ -0,0 +1,99 @@
package com.infendro.account.model
import com.infendro.account.model.entity.MigrationTable
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.MigrationRepository
import com.infendro.account.util.SecureHasher
import io.ktor.server.application.*
import org.intellij.lang.annotations.Language
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject
fun Application.migrate() {
transaction {
SchemaUtils.create(MigrationTable)
}
migration(
"initial",
"""
CREATE TABLE access
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL
);
CREATE TABLE account
(
id BIGSERIAL
PRIMARY KEY,
username TEXT
UNIQUE
NOT NULL,
password_hash TEXT
NOT NULL,
password_salt TEXT
NOT NULL,
secret TEXT
NOT NULL,
role TEXT
NOT NULL
);
CREATE TABLE session
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL,
account_id BIGINT
REFERENCES account (id) ON DELETE CASCADE
NOT NULL
);
""".trimIndent()
)
migration("owner") {
val accounts by inject<AccountRepository>()
accounts.insert {
val salt = SecureHasher.generateSalt()
this.username = "infendro"
this.passwordHash = SecureHasher.hash("password", salt)
this.passwordSalt = salt
this.secret = "deadbeef"
this.role = Role.OWNER
}
}
}
private fun Application.migration(
name: String,
block: Transaction.() -> Unit,
) {
val migrations by inject<MigrationRepository>()
migrations
.singleOrNull { MigrationTable.name eq name }
?.run { return }
transaction {
block()
}
migrations.insert {
this.name = name
}
}
private fun Application.migration(
name: String,
@Language("sql") sql: String,
) = migration(name) {
exec(sql)
}

View File

@@ -5,12 +5,12 @@ import org.jetbrains.exposed.dao.LongEntityClass
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.dao.id.LongIdTable
object InitializationTable : LongIdTable("initialization", "id") { object MigrationTable : LongIdTable("migration", "id") {
val name = text("name") val name = text("name")
} }
class InitializationEntity(id: EntityID<Long>) : LongEntity(id) { class MigrationEntity(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<InitializationEntity>(InitializationTable) companion object : LongEntityClass<MigrationEntity>(MigrationTable)
var name by InitializationTable.name var name by MigrationTable.name
} }

View File

@@ -1,5 +0,0 @@
package com.infendro.account.model.repository
import com.infendro.account.model.entity.InitializationEntity
class InitializationRepository : Repository<InitializationEntity>(InitializationEntity)

View File

@@ -0,0 +1,5 @@
package com.infendro.account.model.repository
import com.infendro.account.model.entity.MigrationEntity
class MigrationRepository : Repository<MigrationEntity>(MigrationEntity)

View File

@@ -1,44 +0,0 @@
CREATE TABLE initialization
(
id BIGSERIAL
PRIMARY KEY,
name TEXT
UNIQUE
NOT NULL
);
CREATE TABLE access
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL
);
CREATE TABLE account
(
id BIGSERIAL
PRIMARY KEY,
username TEXT
UNIQUE
NOT NULL,
password_hash TEXT
NOT NULL,
password_salt TEXT
NOT NULL,
secret TEXT
NOT NULL,
role TEXT
NOT NULL
);
CREATE TABLE session
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL,
account_id BIGINT
REFERENCES account (id) ON DELETE CASCADE
NOT NULL
);