feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -0,0 +1,27 @@
{
"reflection": [
{ "type": "scala.Tuple1[]" },
{ "type": "scala.Tuple2[]" },
{ "type": "scala.Tuple3[]" },
{ "type": "scala.Tuple4[]" },
{ "type": "scala.Tuple5[]" },
{ "type": "scala.Tuple6[]" },
{ "type": "scala.Tuple7[]" },
{ "type": "scala.Tuple8[]" },
{ "type": "scala.Tuple9[]" },
{ "type": "scala.Tuple10[]" },
{ "type": "scala.Tuple11[]" },
{ "type": "scala.Tuple12[]" },
{ "type": "scala.Tuple13[]" },
{ "type": "scala.Tuple14[]" },
{ "type": "scala.Tuple15[]" },
{ "type": "scala.Tuple16[]" },
{ "type": "scala.Tuple17[]" },
{ "type": "scala.Tuple18[]" },
{ "type": "scala.Tuple19[]" },
{ "type": "scala.Tuple20[]" },
{ "type": "scala.Tuple21[]" },
{ "type": "scala.Tuple22[]" },
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
]
}
@@ -0,0 +1,41 @@
quarkus:
application:
name: nowchess-store
http.port: 8085
config:
yaml:
enabled: true
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
datasource:
db-kind: postgresql
username: ${DB_USER:nowchess}
password: ${DB_PASSWORD:nowchess}
jdbc:
url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess}
hibernate-orm:
database:
generation: update
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
"%test":
quarkus:
datasource:
db-kind: h2
username: sa
password: ""
jdbc:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
hibernate-orm:
database:
generation: drop-and-create
nowchess:
redis:
host: localhost
port: 6379
prefix: test-store
@@ -0,0 +1,17 @@
package de.nowchess.store.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
@@ -0,0 +1,13 @@
package de.nowchess.store.config
import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.store.domain.GameRecord
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[GameRecord],
classOf[GameWritebackEventDto],
),
)
class NativeReflectionConfig
@@ -0,0 +1,12 @@
package de.nowchess.store.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,98 @@
package de.nowchess.store.domain
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
import jakarta.persistence.*
import scala.compiletime.uninitialized
import java.time.Instant
@Entity
@Table(
name = "game_records",
indexes = Array(
new Index(name = "idx_game_records_white_id", columnList = "whiteId"),
new Index(name = "idx_game_records_black_id", columnList = "blackId"),
),
)
class GameRecord extends PanacheEntityBase:
// scalafix:off DisableSyntax.var
@Id
@Column(nullable = false)
var gameId: String = uninitialized
@Column(nullable = false, columnDefinition = "TEXT")
var fen: String = uninitialized
@Column(nullable = false, columnDefinition = "TEXT")
var pgn: String = uninitialized
@Column(nullable = false)
var moveCount: Int = 0
@Column(nullable = false)
var createdAt: Instant = uninitialized
@Column(nullable = false)
var updatedAt: Instant = uninitialized
// Player info
@Column(nullable = false)
var whiteId: String = uninitialized
@Column(nullable = false)
var whiteName: String = uninitialized
@Column(nullable = false)
var blackId: String = uninitialized
@Column(nullable = false)
var blackName: String = uninitialized
@Column(nullable = false)
var mode: String = uninitialized
// Time control
@Column
var limitSeconds: java.lang.Integer = uninitialized
@Column
var incrementSeconds: java.lang.Integer = uninitialized
@Column
var daysPerMove: java.lang.Integer = uninitialized
// Clock state
@Column
var whiteRemainingMs: java.lang.Long = uninitialized
@Column
var blackRemainingMs: java.lang.Long = uninitialized
@Column
var incrementMs: java.lang.Long = uninitialized
@Column
var clockLastTickAt: java.lang.Long = uninitialized
@Column
var clockMoveDeadline: java.lang.Long = uninitialized
@Column
var clockActiveColor: String = uninitialized
// Game meta
@Column(nullable = false)
var resigned: Boolean = false
@Column
var pendingDrawOffer: String = uninitialized
@Column
var pendingTakebackOffer: String = uninitialized
// Game result
@Column
var result: String = uninitialized
@Column
var terminationReason: String = uninitialized
// scalafix:on
@@ -0,0 +1,29 @@
package de.nowchess.store.redis
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.store.service.GameWritebackService
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.function.Consumer
@ApplicationScoped
class GameWritebackStreamListener:
@Inject
// scalafix:off DisableSyntax.var
var redis: RedisDataSource = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var writebackService: GameWritebackService = uninitialized
// scalafix:on
@PostConstruct
def startListening(): Unit =
val handler: Consumer[String] = json =>
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
.foreach(writebackService.writeBack)
redis.pubsub(classOf[String]).subscribe("game-writeback", handler)
()
@@ -0,0 +1,46 @@
package de.nowchess.store.repository
import de.nowchess.store.domain.GameRecord
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
@ApplicationScoped
class GameRecordRepository:
@Inject
// scalafix:off DisableSyntax.var
var em: EntityManager = uninitialized
// scalafix:on
def findByGameId(gameId: String): Option[GameRecord] =
Option(em.find(classOf[GameRecord], gameId))
def persist(record: GameRecord): Unit =
em.persist(record)
def merge(record: GameRecord): Unit =
em.merge(record)
def findByPlayerId(playerId: String, offset: Int, limit: Int): List[GameRecord] =
em.createQuery(
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result != null ORDER BY g.updatedAt DESC",
classOf[GameRecord],
).setParameter("id", playerId)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList
.asScala
.toList
def findByPlayerIdRunning(playerId: String, offset: Int, limit: Int): List[GameRecord] =
em.createQuery(
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result = null ORDER BY g.updatedAt DESC",
classOf[GameRecord],
).setParameter("id", playerId)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList
.asScala
.toList
@@ -0,0 +1,45 @@
package de.nowchess.store.resource
import de.nowchess.store.repository.GameRecordRepository
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import jakarta.ws.rs.DefaultValue
import scala.compiletime.uninitialized
@Path("/game")
@ApplicationScoped
class StoreGameResource:
@Inject
// scalafix:off DisableSyntax.var
var repository: GameRecordRepository = uninitialized
// scalafix:on
@GET
@Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getGame(@PathParam("gameId") gameId: String): Response =
repository
.findByGameId(gameId)
.fold(Response.status(404).build())(r => Response.ok(r).build())
@GET
@Path("/running/{playerId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getRunning(
@PathParam("playerId") playerId: String,
@QueryParam("offset") @DefaultValue("0") offset: Int,
@QueryParam("limit") @DefaultValue("20") limit: Int,
): Response =
Response.ok(repository.findByPlayerIdRunning(playerId, offset, limit)).build()
@GET
@Path("/history/{playerId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getHistory(
@PathParam("playerId") playerId: String,
@QueryParam("offset") @DefaultValue("0") offset: Int,
@QueryParam("limit") @DefaultValue("20") limit: Int,
): Response =
Response.ok(repository.findByPlayerId(playerId, offset, limit)).build()
@@ -0,0 +1,75 @@
package de.nowchess.store.service
import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.store.domain.GameRecord
import de.nowchess.store.repository.GameRecordRepository
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import scala.compiletime.uninitialized
import java.time.Instant
@ApplicationScoped
class GameWritebackService:
@Inject
// scalafix:off DisableSyntax.var
var repository: GameRecordRepository = uninitialized
// scalafix:on
@Transactional
def writeBack(event: GameWritebackEventDto): Unit =
repository.findByGameId(event.gameId) match
case None =>
val record = new GameRecord
record.gameId = event.gameId
record.fen = event.fen
record.pgn = event.pgn
record.moveCount = event.moveCount
record.whiteId = event.whiteId
record.whiteName = event.whiteName
record.blackId = event.blackId
record.blackName = event.blackName
record.mode = event.mode
record.resigned = event.resigned
record.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
record.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
record.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
record.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
record.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
record.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
record.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
record.clockActiveColor = event.clockActiveColor.orNull
record.pendingDrawOffer = event.pendingDrawOffer.orNull
record.result = event.result.orNull
record.terminationReason = event.terminationReason.orNull
record.createdAt = Instant.now()
record.updatedAt = Instant.now()
repository.persist(record)
case Some(r) if event.moveCount > r.moveCount || event.pgn != r.pgn =>
r.fen = event.fen
r.pgn = event.pgn
r.moveCount = event.moveCount
r.whiteId = event.whiteId
r.whiteName = event.whiteName
r.blackId = event.blackId
r.blackName = event.blackName
r.mode = event.mode
r.resigned = event.resigned
r.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
r.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
r.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
r.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
r.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
r.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
r.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
r.clockActiveColor = event.clockActiveColor.orNull
r.pendingDrawOffer = event.pendingDrawOffer.orNull
r.pendingTakebackOffer = event.pendingTakebackRequest.orNull
r.result = event.result.orNull
r.terminationReason = event.terminationReason.orNull
r.updatedAt = Instant.now()
repository.merge(r)
case _ => ()