feat: initial db persistence

This commit is contained in:
Finn Linck Ryan 2025-12-13 17:09:08 +01:00
parent e8abfb18eb
commit 2c4995f9d7
9 changed files with 342 additions and 46 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
DB_URL=jdbc:postgresql://localhost:5432/homepage
DB_MIGRATION=src/main/resources/db/migration
DB_USERNAME=postgres
DB_PASSWORD=postgres

View file

@ -1,14 +1,42 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import io.github.klahap.dotenv.DotEnvBuilder
import org.flywaydb.gradle.task.FlywayMigrateTask
import org.gradle.api.JavaVersion.VERSION_21 import org.gradle.api.JavaVersion.VERSION_21
import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import kotlin.io.path.Path import kotlin.io.path.Path
plugins { val env = DotEnvBuilder.dotEnv {
kotlin("jvm") version "2.2.20" addFile("$rootDir/.env")
addSystemEnv()
}
id("gg.jte.gradle") version "3.2.1" val envDbUrl: String = env["DB_URL"] ?: ""
id("com.gradleup.shadow") version "9.3.0" val envDbMigration: String = env["DB_MIGRATIONS"] ?: "src/main/resources/db/migration"
val envDbUsername: String = env["DB_USERNAME"] ?: ""
val envDbPassword: String = env["DB_PASSWORD"] ?: ""
val generatedResourcesDirectory = "${layout.buildDirectory.get()}/generated-resources"
val generatedSourcesDirectory = "${layout.buildDirectory.get()}/generated-src"
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
alias(libs.plugins.dotenv.plugin)
alias(libs.plugins.jte)
alias(libs.plugins.flyway)
alias(libs.plugins.jooq.codegen.gradle)
alias(libs.plugins.taskinfo)
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
} }
buildscript { buildscript {
@ -18,32 +46,20 @@ buildscript {
} }
dependencies { dependencies {
classpath(libs.postgresql)
classpath(libs.flyway.database.postgresql)
} }
} }
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
jte {
sourceDirectory.set(Path("src/main/kte"))
targetDirectory.set(Path("${layout.buildDirectory.get()}/classes/jte"))
precompile()
}
sourceSets.main { sourceSets.main {
resources.srcDir("${layout.buildDirectory.get()}/classes/jte") resources.srcDir("$generatedResourcesDirectory/jte")
} kotlin.srcDir("$generatedSourcesDirectory/jooq")
repositories {
mavenCentral()
} }
tasks { tasks {
withType<KotlinJvmCompile>().configureEach { withType<KotlinJvmCompile>().configureEach {
dependsOn("jooqCodegen")
compilerOptions { compilerOptions {
allWarningsAsErrors = false allWarningsAsErrors = false
jvmTarget.set(JVM_21) jvmTarget.set(JVM_21)
@ -55,6 +71,14 @@ tasks {
useJUnitPlatform() useJUnitPlatform()
} }
withType<FlywayMigrateTask> {
dependsOn("initDb")
}
named("precompileJte") {
dependsOn("compileKotlin")
}
named<ShadowJar>("shadowJar") { named<ShadowJar>("shadowJar") {
manifest { manifest {
attributes("Main-Class" to "at.dokkae.homepage.HomepageKt") attributes("Main-Class" to "at.dokkae.homepage.HomepageKt")
@ -62,13 +86,19 @@ tasks {
dependsOn("precompileJte") dependsOn("precompileJte")
from("${layout.buildDirectory.get()}/classes/jte") mustRunAfter("flywayMigrate", "jooqCodegen")
from("$generatedResourcesDirectory/jte")
archiveFileName.set("app.jar") archiveFileName.set("app.jar")
exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA")
} }
register("buildDocker") {
}
java { java {
sourceCompatibility = VERSION_21 sourceCompatibility = VERSION_21
targetCompatibility = VERSION_21 targetCompatibility = VERSION_21
@ -76,17 +106,101 @@ tasks {
} }
dependencies { dependencies {
implementation(platform("org.http4k:http4k-bom:6.23.1.0")) implementation(platform(libs.http4k.bom))
implementation("org.http4k:http4k-client-okhttp")
implementation("org.http4k:http4k-core") implementation(libs.dotenv)
implementation("org.http4k:http4k-server-jetty")
implementation("org.http4k:http4k-template-jte") implementation(libs.bundles.http4k)
implementation("org.http4k:http4k-web-htmx") implementation(libs.jte.kotlin)
implementation("gg.jte:jte-kotlin:3.2.1") implementation(libs.bundles.database)
testImplementation("org.http4k:http4k-testing-approval")
testImplementation("org.http4k:http4k-testing-hamkrest") testImplementation(libs.bundles.testing)
testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.0")
testImplementation("org.junit.jupiter:junit-jupiter-engine:6.0.0") jooqCodegen(libs.jooq.meta)
testImplementation("org.junit.platform:junit-platform-launcher:6.0.0") jooqCodegen(libs.jooq.postgres)
} }
// ========== JTE Templating ==========
jte {
sourceDirectory.set(Path("src/main/kte"))
targetDirectory.set(Path("$generatedResourcesDirectory/jte"))
precompile()
}
// ========== FlyWay ==========
flyway {
url = envDbUrl
user = envDbUsername
password = envDbPassword
locations = arrayOf("filesystem:$envDbMigration")
baselineOnMigrate = true
validateMigrationNaming = true
}
tasks.register("initDb") {
doFirst {
println("Database Configuration:")
println(" Raw URL from env: $envDbUrl")
println(" Resolved URL: $envDbUrl")
println(" Migrations: $envDbMigration")
println(" Credentials:")
println(" Username: $envDbUsername")
println(" Password: ${"*".repeat(envDbPassword.length)}")
}
}
tasks.named("flywayMigrate") {
finalizedBy("jooqCodegen")
}
// ========== Jooq ==========
jooq {
configuration {
logging = org.jooq.meta.jaxb.Logging.WARN
jdbc {
driver = "org.postgresql.Driver"
url = envDbUrl
user = envDbUsername
password = envDbPassword
}
generator {
name = "org.jooq.codegen.KotlinGenerator"
database {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
// SQLite specific configuration
includes = ".*"
excludes = """
flyway_.*|
pg_.*|
information_schema.*
""".trimMargin().replace("\n", "")
}
generate {
// Recommended settings for Kotlin
isDeprecated = false
isRecords = true
isImmutablePojos = true
isFluentSetters = true
isKotlinNotNullRecordAttributes = true
isKotlinNotNullPojoAttributes = true
isKotlinNotNullInterfaceAttributes = true
isPojosAsKotlinDataClasses = true
}
target {
packageName = "at.dokkae.homepage.generated.jooq"
directory = "$generatedSourcesDirectory/jooq"
}
strategy {
name = "org.jooq.codegen.DefaultGeneratorStrategy"
}
}
}
}

View file

@ -1,2 +1,2 @@
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=false

83
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,83 @@
[versions]
kotlin = "2.2.20"
shadow = "9.3.0"
dotenv-plugin = "1.1.3"
dotenv = "6.5.1"
http4k = "6.23.1.0"
jte = "3.2.1"
flyway = "11.19.0"
jooq = "3.20.10"
junit = "6.0.0"
postgresql = "42.7.7"
taskinfo = "3.0.0"
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
dotenv-plugin = { id = "io.github.klahap.dotenv", version.ref = "dotenv-plugin" }
jte = { id = "gg.jte.gradle", version.ref = "jte" }
flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" }
jooq-codegen-gradle = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" }
taskinfo = { id = "org.barfuin.gradle.taskinfo", version.ref = "taskinfo" }
[bundles]
http4k = [
"http4k-client-okhttp",
"http4k-core",
"http4k-server-jetty",
"http4k-template-jte",
"http4k-web-htmx"
]
testing = [
"http4k-testing-approval",
"http4k-testing-hamkrest",
"junit-jupiter-api",
"junit-jupiter-engine",
"junit-platform-launcher"
]
database = [
"flyway-core",
"jooq",
"jooq-meta",
"jooq-codegen",
"jooq-postgres"
]
[libraries]
# Environment Management
dotenv = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
# HTTP4K Platform (BOM)
http4k-bom = { module = "org.http4k:http4k-bom", version.ref = "http4k" }
# HTTP4K Dependencies
http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp" }
http4k-core = { module = "org.http4k:http4k-core" }
http4k-server-jetty = { module = "org.http4k:http4k-server-jetty" }
http4k-template-jte = { module = "org.http4k:http4k-template-jte" }
http4k-web-htmx = { module = "org.http4k:http4k-web-htmx" }
# JTE Templating
jte-kotlin = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
# Database Driver
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
# Flyway
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway"}
# Jooq
jooq = { module = "org.jooq:jooq", version.ref = "jooq" }
jooq-meta = { module = "org.jooq:jooq-meta", version.ref = "jooq" }
jooq-codegen = { module = "org.jooq:jooq-codegen", version.ref = "jooq" }
jooq-postgres = { module = "org.jooq:jooq-postgres-extensions", version.ref = "jooq" }
# Testing
http4k-testing-approval = { module = "org.http4k:http4k-testing-approval" }
http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }

View file

@ -1,7 +1,10 @@
package at.dokkae.homepage package at.dokkae.homepage
import at.dokkae.homepage.extensions.Precompiled import at.dokkae.homepage.extensions.Precompiled
import at.dokkae.homepage.repository.MessageRepository
import at.dokkae.homepage.repository.impls.JooqMessageRepository
import at.dokkae.homepage.templates.Index import at.dokkae.homepage.templates.Index
import io.github.cdimascio.dotenv.dotenv
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.core.Method.* import org.http4k.core.Method.*
import org.http4k.core.Response import org.http4k.core.Response
@ -22,30 +25,43 @@ import org.http4k.sse.SseResponse
import org.http4k.template.JTETemplates import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel import org.http4k.template.ViewModel
import org.jetbrains.kotlin.backend.common.push import org.jetbrains.kotlin.backend.common.push
import org.jooq.SQLDialect
import org.jooq.impl.DSL
import java.sql.DriverManager
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.thread import kotlin.concurrent.thread
data class Message ( data class Message(
val author: String, val author: String,
val content: String, val content: String,
val createdAt: Instant = Instant.now(),
val id: UUID = UUID.randomUUID(), val id: UUID = UUID.randomUUID(),
val createdAt: Instant = Instant.now(),
val updatedAt: Instant? = null
) : ViewModel { ) : ViewModel {
init {
require(author.length <= 31) { "Author must be 31 characters or less" }
require(content.length <= 255) { "Content must be 255 characters or less" }
}
override fun template(): String = "partials/Message" override fun template(): String = "partials/Message"
} }
fun main() { fun main() {
val messages = CopyOnWriteArrayList<Message>() val env = dotenv()
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/classes/jte")
messages.push(Message("Kurisu", "Hello World!")) val connection = DriverManager.getConnection(env["DB_URL"], env["DB_USERNAME"], env["DB_PASSWORD"])
messages.push(Message("Violet", "Haii Kurisu!")) val dslContext = DSL.using(connection, SQLDialect.POSTGRES)
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/generated-resources/jte")
val indexHandler: HttpHandler = { val indexHandler: HttpHandler = {
Response(Status.OK).body(renderer(Index(messages))) Response(Status.OK).body(renderer(Index(messageRepository.findAll())))
} }
val sse = sse( val sse = sse(
@ -72,12 +88,12 @@ fun main() {
val msg = Message(author, message) val msg = Message(author, message)
val sseMsg = SseMessage.Data(renderer(msg)) val sseMsg = SseMessage.Data(renderer(msg))
messages.push(msg) messageRepository.save(msg)
subscribers.forEach { subscribers.forEach {
thread { it.send(sseMsg) } thread { it.send(sseMsg) }
} }
Response(Status.OK) Response(Status.CREATED)
} }
} }
) )

View file

@ -0,0 +1,8 @@
package at.dokkae.homepage.repository
import at.dokkae.homepage.Message
interface MessageRepository {
fun save(message: Message): Message
fun findAll(): List<Message>
}

View file

@ -0,0 +1,40 @@
package at.dokkae.homepage.repository.impls
import at.dokkae.homepage.Message
import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord
import at.dokkae.homepage.generated.jooq.tables.references.MESSAGE
import at.dokkae.homepage.repository.MessageRepository
import org.jooq.DSLContext
import org.jooq.impl.DSL
class JooqMessageRepository(
private val dslContext: DSLContext
) : MessageRepository {
override fun save(message: Message): Message = dslContext.transactionResult { config ->
val ctx = DSL.using(config)
ctx.insertInto(MESSAGE)
.set(MESSAGE.ID, message.id)
.set(MESSAGE.AUTHOR, message.author)
.set(MESSAGE.CONTENT, message.content)
.onDuplicateKeyUpdate()
.set(MESSAGE.AUTHOR, message.author)
.set(MESSAGE.CONTENT, message.content)
.returning()
.fetchOne()!!
.toMessage()
}
override fun findAll(): List<Message> = dslContext.selectFrom(MESSAGE)
.orderBy(MESSAGE.CREATED_AT.desc())
.fetch { it.toMessage() }
private fun MessageRecord.toMessage(): Message = Message(
id = this.id,
author = this.author,
content = this.content,
createdAt = this.createdAt!!.toInstant(),
updatedAt = this.updatedAt?.toInstant(),
)
}

View file

@ -0,0 +1,18 @@
create or replace function handle_timestamps()
returns trigger as
$$
begin
if lower(tg_op) = 'insert' then
new.created_at = coalesce(new.created_at, current_timestamp);
new.updated_at = coalesce(new.updated_at, current_timestamp);
elsif lower(tg_op) = 'update' then
if new.created_at is distinct from old.created_at then
raise exception 'Direct modification of created_at is not allowed.';
end if;
new.updated_at = current_timestamp;
end if;
return new;
end;
$$ language plpgsql;

View file

@ -0,0 +1,13 @@
create table message (
id uuid not null primary key,
author varchar(31) not null,
content varchar(255) not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz
);
create trigger handle_message_timestamps
before insert or update on message
for each row
execute function handle_timestamps();