Database reorganize, protocol version bump, migrations

This commit is contained in:
2026-08-26 23:13:47 +02:00
parent 7343300536
commit 4fdeafb73b
31 changed files with 1058 additions and 114 deletions
@@ -2,6 +2,8 @@ package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.*
import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.Meta
import com.jaytux.phoebench.server.db.Metas
import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.*
import com.jaytux.phoebench.server.handlers.ProjectHandler.accessibleProject
@@ -27,6 +29,10 @@ fun Application.module() {
DB.db
println("Entering module for PhoeBench Server version ${ServerVersion.VERSION}...")
if(Meta.single.revision < Metas.CURRENT_REVISION) {
DB.migrate(Meta.single.revision, Metas.CURRENT_REVISION)
}
install(ContentNegotiation) {
json()
}
@@ -120,12 +126,17 @@ fun Application.module() {
deleteAdmin(Routes.Auth.User.delete, AuthHandler::deleteUser)
getAuth(Routes.home, ProjectHandler::home)
postAuth(Routes.migrate0To1, ProjectHandler::migrate01)
postAuth(Routes.Project.new, ProjectHandler::createProject)
getAuth(Routes.Project.get, ProjectHandler::getProject)
patchAuth(Routes.Project.update, ProjectHandler::updateProject)
deleteAuth(Routes.Project.delete, ProjectHandler::deleteProject)
postAuth(Routes.Benchmark.new, ProjectHandler::createBenchmark)
patchAuth(Routes.Benchmark.update, ProjectHandler::updateBenchmark)
deleteAuth(Routes.Benchmark.delete, ProjectHandler::deleteBenchmark)
postAuth(Routes.Version.new, ProjectHandler::createVersion)
patchAuth(Routes.Version.update, ProjectHandler::updateVersion)
deleteAuth(Routes.Version.delete, ProjectHandler::deleteVersion)
@@ -21,4 +21,6 @@ fun nowPlusDays(days: Int): Instant = nowPlus(days, DateTimeUnit.DAY)
fun nowPlus(time: Int, unit: DateTimeUnit): Instant {
val now = Clock.System.now()
return now.plus(time, unit, systemTZ)
}
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(t3: T3) = Triple(first, second, t3)
@@ -1,9 +1,11 @@
package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.server.DotEnv
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
object DB {
@@ -17,7 +19,7 @@ object DB {
transaction {
SchemaUtils.create(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries)
val migration = MigrationUtils.statementsRequiredForDatabaseMigration(Users, Invites, RefreshTokens, Projects, Versions, Entries, Metas, EntriesV2, Benchmarks)
try {
migration.forEach {
exec(it)
@@ -29,4 +31,29 @@ object DB {
}
}
}
fun migrate(from: Int, to: Int) = when(from) {
0 -> if(to == 1) migrate0To1() else null
else -> null
} ?: throw IllegalArgumentException("Migration $from -> $to is not defined")
private fun migrate0To1() {
transaction {
Users.update(where = { Users.isAdmin eq true }) { it[benchmarkLimit] = -1 }
Users.update(where = { Users.isAdmin eq false }) { it.update(benchmarkLimit, projectLimit) }
}
transaction {
User.all().forEach {
if(it.projects.count() == 0L) {
it.projectVersion = Projects.CURRENT_VERSION
return@forEach
}
}
}
transaction {
Meta.single.revision = 1
}
}
}
@@ -1,10 +1,25 @@
package com.jaytux.phoebench.server.db
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.dao.Entity
import org.jetbrains.exposed.v1.dao.EntityClass
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
class Meta(id: EntityID<Int>) : Entity<Int>(id) {
companion object : EntityClass<Int, Meta>(Metas) {
val single by lazy {
transaction {
val got = all().limit(1).toList()
got.firstOrNull() ?: new {}
}
}
}
var revision by Metas.revision
}
class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, User>(Users)
@@ -13,6 +28,8 @@ class User(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isAdmin by Users.isAdmin
var isOwner by Users.isOwner
var projectLimit by Users.projectLimit
var benchmarkLimit by Users.benchmarkLimit
var projectVersion by Users.projectsVersion
val projects by Project referrersOn Projects.ownerId
val sessions by RefreshToken referrersOn RefreshTokens.userId
@@ -42,8 +59,18 @@ class Project(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var isPublic by Projects.isPublic
var owner by User referencedOn Projects.ownerId
val labels by Version referrersOn Versions.projectId
val entries by Entry referrersOn Entries.projectId
val versions by Version referrersOn Versions.projectId
val benchmarks by Benchmark referrersOn Benchmarks.projectId
}
class Benchmark(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, Benchmark>(Benchmarks)
var projectId by Benchmarks.projectId
var name by Benchmarks.name
val project by Project referencedOn Benchmarks.projectId
val entries by EntryV2 referrersOn EntriesV2.benchmarkId
}
class Version(id: EntityID<Uuid>) : Entity<Uuid>(id) {
@@ -71,4 +98,20 @@ class Entry(id: EntityID<Uuid>) : Entity<Uuid>(id) {
var project by Project referencedOn Entries.projectId
var version by Version referencedOn Entries.versionId
}
class EntryV2(id: EntityID<Uuid>) : Entity<Uuid>(id) {
companion object : EntityClass<Uuid, EntryV2>(EntriesV2)
var benchmarkId by EntriesV2.benchmarkId
var versionId by EntriesV2.versionId
var timestamp by EntriesV2.timestamp
var warmups by EntriesV2.warmups
var measurements by EntriesV2.measurements
var unit by EntriesV2.unit
var input by EntriesV2.input
var hardware by EntriesV2.hardware
var benchmark by Benchmark referencedOn EntriesV2.benchmarkId
var version by Version referencedOn EntriesV2.versionId
}
@@ -2,16 +2,26 @@ package com.jaytux.phoebench.server.db
import com.jaytux.phoebench.common.TimeUnit
import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.core.dao.id.UuidTable
import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.time.Clock
object Metas : IntIdTable() {
const val CURRENT_REVISION = 1
val revision = integer("revision").default(0)
}
object Users : UuidTable() {
val username = varchar("username", 255).uniqueIndex()
val password = varchar("password", 255)
val isAdmin = bool("is_admin").default(false)
val isOwner = bool("is_owner").default(false)
val projectLimit = integer("project_limit").default(1)
val benchmarkLimit = integer("benchmark_limit").default(1)
val projectsVersion = integer("project_version").default(0)
}
object Invites : UuidTable() {
@@ -25,6 +35,8 @@ object RefreshTokens : UuidTable() {
}
object Projects : UuidTable() {
const val CURRENT_VERSION = 1
val ownerId = reference("owner_id", Users, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255)
val isPublic = bool("is_public").default(false)
@@ -34,6 +46,15 @@ object Projects : UuidTable() {
}
}
object Benchmarks : UuidTable() {
val projectId = reference("project_id", Projects, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val name = varchar("name", 255)
init {
uniqueIndex(projectId, name)
}
}
object Versions : UuidTable() {
val label = varchar("label", 255)
val color = varchar("color", 7)
@@ -50,4 +71,15 @@ object Entries : UuidTable() {
val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255)
val hardware = varchar("hardware", 255)
}
object EntriesV2 : UuidTable() {
val benchmarkId = reference("benchmark_id", Benchmarks, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val versionId = reference("version_id", Versions, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val timestamp = timestamp("timestamp").default(Clock.System.now())
val warmups = list<Float>("warmups")
val measurements = list<Float>("measurements")
val unit = enumeration<TimeUnit>("unit")
val input = varchar("input", 255)
val hardware = varchar("hardware", 255)
}
@@ -1,22 +1,31 @@
package com.jaytux.phoebench.server.handlers
import com.jaytux.phoebench.common.BenchmarkRequest
import com.jaytux.phoebench.common.BenchmarkResponse
import com.jaytux.phoebench.common.BenchmarkSummary
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.EmptyResponse
import com.jaytux.phoebench.common.EntryRequest
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Migrate0To1Request
import com.jaytux.phoebench.common.VersionRequest
import com.jaytux.phoebench.common.VersionResponse
import com.jaytux.phoebench.common.NamedID
import com.jaytux.phoebench.common.PartialBenchmarkRequest
import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.PartialVersionRequest
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.server.Buses
import com.jaytux.phoebench.server.app
import com.jaytux.phoebench.server.db.Benchmark
import com.jaytux.phoebench.server.db.Benchmarks
import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry
import com.jaytux.phoebench.server.db.EntryV2
import com.jaytux.phoebench.server.db.Version
import com.jaytux.phoebench.server.db.Versions
import com.jaytux.phoebench.server.db.Project
@@ -27,7 +36,12 @@ import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.launch
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.uuid.Uuid
@@ -40,23 +54,44 @@ object ProjectHandler {
return when {
isEditableBy(user) -> this
isPublic && !forEditing -> this
else -> throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
else -> throw RouteError("Invalid ID.", HttpStatusCode.NotFound)
}
}
context(trns: Transaction)
fun Benchmark.isAccessible(user: User, forEditing: Boolean): Pair<Project, Benchmark> {
return project.isAccessible(user, forEditing) to this
}
fun Transaction.accessibleProject(user: User, id: Uuid, forEditing: Boolean): Project {
val project = Project.findById(id) ?: throw RouteError("Invalid project ID.", HttpStatusCode.NotFound)
return project.isAccessible(user, forEditing)
}
fun Transaction.accessibleBenchmark(user: User, id: Uuid, forEditing: Boolean): Pair<Project, Benchmark> {
val bench = Benchmark.findById(id) ?: throw RouteError("Invalid benchmark ID.", HttpStatusCode.NotFound)
return bench.isAccessible(user, forEditing)
}
fun Transaction.checkMigration(user: User) {
if(user.projectVersion < Projects.CURRENT_VERSION)
throw RouteError("You need to migrate your projects first.", HttpStatusCode.Conflict)
}
context(trns: Transaction)
private fun Project.toResponse(user: User) = ProjectResponse(
id.value, name, NamedID(owner.username, owner.id.value), isPublic, isEditableBy(user),
labels.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) },
entries.orderBy(Entries.timestamp to SortOrder.ASC).map { EntryResponse(it.id.value, it.version.id.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware) })
benchmarks.orderBy(Benchmarks.name to SortOrder.ASC).map { b -> BenchmarkResponse(
b.id.value, b.name, b.entries.map { e ->
EntryResponse(e.id.value, e.benchmarkId.value, e.versionId.value, e.timestamp, e.warmups, e.measurements, e.unit, e.input, e.hardware)
}
) },
versions.orderBy(Versions.label to SortOrder.ASC).map { VersionResponse(it.id.value, it.label, it.color, it.meta) }
)
fun home(user: User, req: EmptyRequest) = transaction {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).map {
val own = user.projects.orderBy(Projects.name to SortOrder.ASC).mapNotNull {
if(it.name.startsWith("<migrate")) return@mapNotNull null
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
}
@@ -64,10 +99,23 @@ object ProjectHandler {
HomeResponse.ProjectSummary(it.id.value, it.name, it.isPublic, NamedID(it.owner.username, it.ownerId.value))
}
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics))
val migrations = mutableListOf<HomeResponse.NeededMigration>()
var version = user.projectVersion
while(version < Projects.CURRENT_VERSION) {
migrations += when(version) {
0 -> HomeResponse.NeededMigration.Migrate0To1(user.projects.map { p ->
HomeResponse.NeededMigration.Migrate0To1.OldProject(p.id.value, p.name, p.versions.map { VersionResponse(it.id.value, it.label, it.color, it.meta) })
})
else -> throw IllegalArgumentException("No migration starting from $version possible.")
}
++version
}
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics, migrations))
}
suspend fun createProject(user: User, req: ProjectRequest) = transaction {
checkMigration(user)
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
@@ -88,11 +136,13 @@ object ProjectHandler {
}
fun getProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, false)
success(proj.toResponse(user))
}
fun updateProject(user: User, req: Pair<Uuid, PartialProjectRequest>) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.first, true)
val changes = req.second
changes.name?.let { proj.name = it }
@@ -113,6 +163,7 @@ object ProjectHandler {
}
fun deleteProject(user: User, req: Uuid) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req, true)
proj.delete()
ServerScope.launch {
@@ -124,8 +175,57 @@ object ProjectHandler {
success(EmptyResponse())
}
fun createVersion(user: User, req: VersionRequest) = transaction {
fun createBenchmark(user: User, req: BenchmarkRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.projectId, true)
val bench = Benchmark.new {
name = req.name
projectId = proj.id
}
val res = BenchmarkResponse(bench.id.value, bench.name, bench.entries.map {
EntryResponse(it.id.value, it.benchmarkId.value, it.versionId.value, it.timestamp, it.warmups, it.measurements, it.unit, it.input, it.hardware)
})
ServerScope.launch {
val projectBus = Buses.projectBus(proj.id.value)
if(proj.isPublic) projectBus.sendAll(ProjectEvent.NewBenchmark(res))
else projectBus.send(user.id.value, ProjectEvent.NewBenchmark(res))
}
success(res)
}
fun updateBenchmark(user: User, req: Pair<Uuid, PartialBenchmarkRequest>) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req.first, true)
val changes = req.second
changes.name?.let { proj.name = it }
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkChanged(
BenchmarkSummary(bench.id.value, bench.name)
))
}
success(EmptyResponse())
}
fun deleteBenchmark(user: User, req: Uuid) = transaction {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req, true)
bench.delete()
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.BenchmarkDeleted(req))
}
success(EmptyResponse())
}
fun createVersion(user: User, req: VersionRequest) = transaction {
checkMigration(user)
val proj = accessibleProject(user, req.benchmarkId, true)
if(req.color.length != 7) throw RouteError("Expected Hex-RGB color (7 characters).", HttpStatusCode.BadRequest)
val ver = Version.new {
label = req.name
@@ -136,13 +236,14 @@ object ProjectHandler {
val res = VersionResponse(ver.id.value, ver.label, ver.color, ver.meta)
ServerScope.launch {
Buses.projectBus(req.projectId).sendAll(ProjectEvent.NewVersion(res))
Buses.projectBus(req.benchmarkId).sendAll(ProjectEvent.NewVersion(res))
}
success(res)
}
fun updateVersion(user: User, req: Pair<Uuid, PartialVersionRequest>) = transaction {
checkMigration(user)
val ver = Version.findById(req.first) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
val changes = req.second
@@ -163,6 +264,7 @@ object ProjectHandler {
}
fun deleteVersion(user: User, req: Uuid) = transaction {
checkMigration(user)
val ver = Version.findById(req) ?: throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
ver.project.isAccessible(user, true)
ver.delete()
@@ -175,14 +277,15 @@ object ProjectHandler {
}
fun createEntry(user: User, req: EntryRequest) = transaction {
val proj = accessibleProject(user, req.projectId, true)
val entry = Entry.new {
checkMigration(user)
val (proj, bench) = accessibleBenchmark(user, req.benchmarkId, true)
val entry = EntryV2.new {
version = when(val l = Version.findById(req.version)) {
null -> throw RouteError("Invalid version ID.", HttpStatusCode.NotFound)
is Version if l.projectId.value != proj.id.value -> throw RouteError("Version is attached to a different project.", HttpStatusCode.Conflict)
else -> l
}
project = proj
benchmarkId = bench.id
measurements = req.measurements
timestamp = req.timestamp
warmups = req.warmups
@@ -191,7 +294,8 @@ object ProjectHandler {
hardware = req.hardware
}
val response = EntryResponse(entry.id.value, entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
val response = EntryResponse(entry.id.value, entry.benchmarkId.value,
entry.version.id.value, entry.timestamp, entry.warmups, entry.measurements, entry.unit, entry.input, entry.hardware)
ServerScope.launch {
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.NewEntry(response))
}
@@ -199,12 +303,82 @@ object ProjectHandler {
}
fun deleteEntry(user: User, req: Uuid) = transaction {
val entry = Entry.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
entry.project.isAccessible(user, true)
checkMigration(user)
val entry = EntryV2.findById(req) ?: throw RouteError("Invalid entry ID.", HttpStatusCode.NotFound)
val (proj, bench) = entry.benchmark.isAccessible(user, true)
entry.delete()
ServerScope.launch {
Buses.projectBus(entry.project.id.value).sendAll(ProjectEvent.EntryDeleted(req))
Buses.projectBus(proj.id.value).sendAll(ProjectEvent.EntryDeleted(req, bench.id.value))
}
success(EmptyResponse())
}
fun migrate01(user: User, req: Migrate0To1Request): Pair<HttpStatusCode, HomeResponse> {
val givenProjectIds = req.conversions.flatMap { it.oldProjectsAsBenchmarks }.toSet()
val totalProjectCount = req.conversions.sumOf { it.oldProjectsAsBenchmarks.size }
if(totalProjectCount != givenProjectIds.size)
throw RouteError("Each (legacy) project should only be bound to a single (migrated) project", HttpStatusCode.BadRequest)
val userProjects = transaction { user.projects.toList() }.map { it.id.value }.toSet()
if(givenProjectIds.any { it !in userProjects })
throw RouteError("Only a user's own projects should be migrated.", HttpStatusCode.Conflict)
transaction {
user.projects.forEach { it.name = "${it.name}#legacy" }
}
transaction {
req.conversions.forEach { (newName, isPublic, olds) ->
val proj = Project.new {
ownerId = user.id
name = newName
this.isPublic = isPublic
}
val versionMap = mutableMapOf<Uuid, EntityID<Uuid>>()
val duplicateMap = mutableMapOf<String, EntityID<Uuid>>()
Version.find { Versions.projectId inList olds }.forEach { oldVersion ->
val targetId = duplicateMap.getOrPut(oldVersion.label) {
oldVersion.projectId = proj.id
oldVersion.id
}
versionMap[oldVersion.id.value] = targetId
}
olds.forEach { oldId ->
val oldProj = accessibleProject(user, oldId, true)
val benchmark = Benchmark.new {
projectId = proj.id
name = oldProj.name.removeSuffix("#legacy")
}
Entry.find { Entries.projectId eq oldId }.forEach {
EntryV2.new {
benchmarkId = benchmark.id
versionId = versionMap[it.versionId.value]!!
timestamp = it.timestamp
warmups = it.warmups
measurements = it.measurements
unit = it.unit
input = it.input
hardware = it.hardware
}
it.delete()
}
}
Versions.deleteWhere {
(Versions.projectId inList olds) and (Versions.id notInList versionMap.values.toList())
}
Projects.deleteWhere {
Projects.id inList olds
}
}
user.projectVersion = 1
}
return home(user, EmptyRequest())
}
}