From 4fcfcd9c932bb0f3db4ab2ae07879c4f9b767bd0 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:13:27 -0700 Subject: [PATCH] fix: migrate patch timestamps to timezone-aware UTC instants Replace LocalDateTime with Instant throughout the patch lifecycle, API, statistics, and time utilities. Persist last_updated as a timezone-aware database timestamp and parse backend timestamps as UTC in the frontend. Add Flyway migrations for H2 and PostgreSQL, including conversion of existing legacy timestamps, and switch Hibernate schema handling to validation. When upgrading a database created by the previous version, configure spring.flyway.placeholders.legacy_timezone to the timezone used by the old deployment if it was not UTC. --- .gitignore | 3 +++ build.gradle.kts | 2 ++ .../patchroulette/controller/ApiController.java | 16 +++++++++++----- .../io/papermc/patchroulette/model/Patch.java | 10 +++++----- .../patchroulette/service/PatchService.java | 12 ++++++------ .../io/papermc/patchroulette/util/TimeUtil.java | 4 ++-- src/main/resources/application.yaml | 9 ++++++++- .../db/migration/h2/V1__create_patch_table.sql | 9 +++++++++ .../h2/V2__convert_last_updated_to_utc.sql | 4 ++++ .../postgresql/V1__create_patch_table.sql | 9 +++++++++ .../V2__convert_last_updated_to_utc.sql | 4 ++++ web/src/lib/components/PatchesTable.svelte | 4 +++- web/src/lib/types.ts | 2 +- 13 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/main/resources/db/migration/h2/V1__create_patch_table.sql create mode 100644 src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql create mode 100644 src/main/resources/db/migration/postgresql/V1__create_patch_table.sql create mode 100644 src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql diff --git a/.gitignore b/.gitignore index 7c30bee..2b385c4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ build database.mv.db database.trace.db db/ +!src/main/resources/db/ +!src/main/resources/db/migration/ +!src/main/resources/db/migration/** .DS_Store diff --git a/build.gradle.kts b/build.gradle.kts index f45d9ba..36693cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") implementation("org.springframework.boot:spring-boot-starter-security") runtimeOnly("com.h2database:h2") // for local runtimeOnly("org.postgresql:postgresql") // for prod diff --git a/src/main/java/io/papermc/patchroulette/controller/ApiController.java b/src/main/java/io/papermc/patchroulette/controller/ApiController.java index a4cd560..5d156a9 100644 --- a/src/main/java/io/papermc/patchroulette/controller/ApiController.java +++ b/src/main/java/io/papermc/patchroulette/controller/ApiController.java @@ -7,7 +7,7 @@ import io.papermc.patchroulette.util.TimeUtil; import jakarta.persistence.EntityNotFoundException; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -50,7 +50,7 @@ public ResponseEntity> getAvailablePatches(@RequestParam final Stri ); } - public record PatchDetails(String path, String status, String responsibleUser, LocalDateTime lastUpdated, Duration duration) {} + public record PatchDetails(String path, String status, String responsibleUser, Instant lastUpdated, Duration duration) {} @PreAuthorize("hasRole('PATCH')") @GetMapping( @@ -60,7 +60,13 @@ public record PatchDetails(String path, String status, String responsibleUser, L public ResponseEntity> getAllPatches(@RequestParam final String minecraftVersion) { return ResponseEntity.ok( this.patchService.getAllPatches(minecraftVersion).stream() - .map(patch -> new PatchDetails(patch.getPath(), patch.getStatus().name(), patch.getResponsibleUser(), patch.getLastUpdated(), patch.getDuration())) + .map(patch -> new PatchDetails( + patch.getPath(), + patch.getStatus().name(), + patch.getResponsibleUser(), + patch.getLastUpdated(), + patch.getDuration() + )) .toList() ); } @@ -232,8 +238,8 @@ public ResponseEntity stats(@RequestParam final String minecraftVersion) // Track the time interval for this patch if it has duration if (patch.getDuration() != null && patch.getLastUpdated() != null) { - LocalDateTime endTime = patch.getLastUpdated(); - LocalDateTime startTime = endTime.minus(patch.getDuration()); + Instant endTime = patch.getLastUpdated(); + Instant startTime = endTime.minus(patch.getDuration()); userIntervals.computeIfAbsent(patch.getResponsibleUser(), k -> new ArrayList<>()) .add(new TimeUtil.TimeInterval(startTime, endTime)); diff --git a/src/main/java/io/papermc/patchroulette/model/Patch.java b/src/main/java/io/papermc/patchroulette/model/Patch.java index 38cd2ac..952bee9 100644 --- a/src/main/java/io/papermc/patchroulette/model/Patch.java +++ b/src/main/java/io/papermc/patchroulette/model/Patch.java @@ -8,7 +8,7 @@ import jakarta.persistence.IdClass; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @IdClass(PatchId.class) @@ -25,7 +25,7 @@ public class Patch { private Status status; private String responsibleUser; - private LocalDateTime lastUpdated; + private Instant lastUpdated; private Duration duration; public Patch() { @@ -63,11 +63,11 @@ public void setResponsibleUser(final String responsibleUser) { this.responsibleUser = responsibleUser; } - public LocalDateTime getLastUpdated() { + public Instant getLastUpdated() { return lastUpdated; } - public void setLastUpdated(LocalDateTime lastUpdated) { + public void setLastUpdated(Instant lastUpdated) { this.lastUpdated = lastUpdated; } @@ -81,7 +81,7 @@ public void setDuration(Duration duration) { public void updateDuration() { if (this.lastUpdated != null) { - final Duration duration = Duration.between(this.lastUpdated, LocalDateTime.now()); + final Duration duration = Duration.between(this.lastUpdated, Instant.now()); if (this.duration == null) { this.duration = duration; } else { diff --git a/src/main/java/io/papermc/patchroulette/service/PatchService.java b/src/main/java/io/papermc/patchroulette/service/PatchService.java index 5f097bf..22c88c6 100644 --- a/src/main/java/io/papermc/patchroulette/service/PatchService.java +++ b/src/main/java/io/papermc/patchroulette/service/PatchService.java @@ -5,7 +5,7 @@ import io.papermc.patchroulette.model.Status; import io.papermc.patchroulette.repository.PatchRepository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +29,7 @@ public void setPatches(final String minecraftVersion, final List paths) patch.setPath(path); patch.setStatus(Status.AVAILABLE); patch.setMinecraftVersion(minecraftVersion); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); return patch; }).toList(); @@ -58,7 +58,7 @@ public List startWorkOnPatches(final String minecraftVersion, final List } patch.setStatus(Status.WIP); patch.setResponsibleUser(user); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); startedPatches.add(path); } @@ -74,7 +74,7 @@ public void cancelWorkOnPatch(final PatchId patchId) { patch.setStatus(Status.AVAILABLE); patch.setResponsibleUser(null); patch.setDuration(null); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } @@ -89,7 +89,7 @@ public void finishWorkOnPatch(final PatchId patchId, final String user) { } patch.setStatus(Status.DONE); patch.updateDuration(); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } @@ -101,7 +101,7 @@ public void undoPatch(final PatchId patchId, final String user) { } patch.setStatus(Status.WIP); patch.setResponsibleUser(user); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } diff --git a/src/main/java/io/papermc/patchroulette/util/TimeUtil.java b/src/main/java/io/papermc/patchroulette/util/TimeUtil.java index 1e54fce..4feeebe 100644 --- a/src/main/java/io/papermc/patchroulette/util/TimeUtil.java +++ b/src/main/java/io/papermc/patchroulette/util/TimeUtil.java @@ -1,7 +1,7 @@ package io.papermc.patchroulette.util; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -9,7 +9,7 @@ public final class TimeUtil { private TimeUtil() { } - public record TimeInterval(LocalDateTime start, LocalDateTime end) {} + public record TimeInterval(Instant start, Instant end) {} public static List mergeOverlappingIntervals(List intervals) { if (intervals.isEmpty()) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index edd529d..f8800da 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,6 +13,13 @@ spring: username: admin password: password driver-class-name: org.h2.Driver + flyway: + baseline-on-migrate: true + baseline-version: 1 + locations: classpath:db/migration/{vendor} + placeholders: + # Timezone used by the old LocalDateTime values during legacy migration. + legacy_timezone: UTC jpa: hibernate: - ddl-auto: update + ddl-auto: validate diff --git a/src/main/resources/db/migration/h2/V1__create_patch_table.sql b/src/main/resources/db/migration/h2/V1__create_patch_table.sql new file mode 100644 index 0000000..9137789 --- /dev/null +++ b/src/main/resources/db/migration/h2/V1__create_patch_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS patch ( + minecraft_version VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + status TINYINT, + responsible_user VARCHAR(255), + last_updated TIMESTAMP(6), + duration NUMERIC(21), + PRIMARY KEY (minecraft_version, path) +); diff --git a/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql b/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql new file mode 100644 index 0000000..16bbf20 --- /dev/null +++ b/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql @@ -0,0 +1,4 @@ +-- Legacy LocalDateTime values are interpreted using the deployment timezone. +ALTER TABLE patch + ALTER COLUMN last_updated TIMESTAMP(6) WITH TIME ZONE + USING CAST(FORMATDATETIME(last_updated, 'yyyy-MM-dd HH:mm:ss.SSSSSS') || ' ${legacy_timezone}' AS TIMESTAMP(6) WITH TIME ZONE); diff --git a/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql b/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql new file mode 100644 index 0000000..c1396c5 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS patch ( + minecraft_version VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + status SMALLINT, + responsible_user VARCHAR(255), + last_updated TIMESTAMP(6), + duration NUMERIC(21), + PRIMARY KEY (minecraft_version, path) +); diff --git a/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql b/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql new file mode 100644 index 0000000..382b9c7 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql @@ -0,0 +1,4 @@ +-- Legacy LocalDateTime values are interpreted using the deployment timezone. +ALTER TABLE patch + ALTER COLUMN last_updated TYPE TIMESTAMP(6) WITH TIME ZONE + USING last_updated AT TIME ZONE '${legacy_timezone}'; diff --git a/web/src/lib/components/PatchesTable.svelte b/web/src/lib/components/PatchesTable.svelte index b781183..d1302ce 100644 --- a/web/src/lib/components/PatchesTable.svelte +++ b/web/src/lib/components/PatchesTable.svelte @@ -53,7 +53,9 @@ filter: true, floatingFilter: true, valueFormatter: (params) => - params.data?.lastUpdated ? DateTime.fromISO(params.data.lastUpdated).toLocal().toLocaleString(DateTime.DATETIME_SHORT) : "", + params.data?.lastUpdated + ? DateTime.fromISO(params.data.lastUpdated, { zone: "utc" }).toLocal().toLocaleString(DateTime.DATETIME_SHORT) + : "", }, ], getRowId: (params) => params.data.path, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e2a5c65..c4ded43 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -8,7 +8,7 @@ export type PatchDetails = { path: string; status: PatchStatus; responsibleUser: string; - lastUpdated: string; + lastUpdated: string; // ISO 8601 UTC datetime duration: string; };