diff --git a/commafeed-server/pom.xml b/commafeed-server/pom.xml
index 6a75467d..07b40915 100644
--- a/commafeed-server/pom.xml
+++ b/commafeed-server/pom.xml
@@ -405,9 +405,13 @@
com.h2database
h2
-
- 2.1.214
+
+ com.manticore-projects.tools
+ h2migrationtool
+ 1.4
+
+
com.mysql
mysql-connector-j
diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java
index 7f8e79a3..bee52d18 100644
--- a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java
+++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java
@@ -1,6 +1,8 @@
package com.commafeed;
import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Set;
@@ -24,8 +26,9 @@ import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserSettings;
-import com.commafeed.backend.service.DatabaseStartupService;
import com.commafeed.backend.service.UserService;
+import com.commafeed.backend.service.db.DatabaseStartupService;
+import com.commafeed.backend.service.db.H2MigrationService;
import com.commafeed.backend.task.ScheduledTask;
import com.commafeed.frontend.auth.PasswordConstraintValidator;
import com.commafeed.frontend.auth.SecurityCheckFactoryProvider;
@@ -58,6 +61,7 @@ import io.dropwizard.configuration.DefaultConfigurationFactoryFactory;
import io.dropwizard.configuration.EnvironmentVariableSubstitutor;
import io.dropwizard.configuration.SubstitutingSourceProvider;
import io.dropwizard.core.Application;
+import io.dropwizard.core.ConfiguredBundle;
import io.dropwizard.core.setup.Bootstrap;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.db.DataSourceFactory;
@@ -93,6 +97,30 @@ public class CommaFeedApplication extends Application {
configureEnvironmentSubstitutor(bootstrap);
configureObjectMapper(bootstrap.getObjectMapper());
+ // run h2 migration as the first bundle because we need to migrate before hibernate is initialized
+ bootstrap.addBundle(new ConfiguredBundle() {
+ @Override
+ public void run(CommaFeedConfiguration config, Environment environment) throws Exception {
+ DataSourceFactory dataSourceFactory = config.getDataSourceFactory();
+ String url = dataSourceFactory.getUrl();
+ if (isFileBasedH2(url)) {
+ Path path = getFilePath(url);
+ String user = dataSourceFactory.getUser();
+ String password = dataSourceFactory.getPassword();
+ new H2MigrationService().migrateIfNeeded(path, user, password);
+ }
+ }
+
+ private boolean isFileBasedH2(String url) {
+ return url.startsWith("jdbc:h2:") && !url.startsWith("jdbc:h2:mem:");
+ }
+
+ private Path getFilePath(String url) {
+ String name = url.substring("jdbc:h2:".length()).split(";")[0];
+ return Paths.get(name + ".mv.db");
+ }
+ });
+
bootstrap.addBundle(hibernateBundle = new HibernateBundle<>(AbstractModel.class, Feed.class, FeedCategory.class, FeedEntry.class,
FeedEntryContent.class, FeedEntryStatus.class, FeedEntryTag.class, FeedSubscription.class, User.class, UserRole.class,
UserSettings.class) {
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java
similarity index 96%
rename from commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java
rename to commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java
index 336a05cf..a8f54d4e 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseCleaningService.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java
@@ -1,4 +1,4 @@
-package com.commafeed.backend.service;
+package com.commafeed.backend.service.db;
import java.time.Instant;
import java.util.List;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java
similarity index 94%
rename from commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java
rename to commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java
index 6675b67a..865118e6 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/service/DatabaseStartupService.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java
@@ -1,4 +1,4 @@
-package com.commafeed.backend.service;
+package com.commafeed.backend.service.db;
import java.util.HashMap;
import java.util.Map;
@@ -9,6 +9,7 @@ import org.hibernate.SessionFactory;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO;
+import com.commafeed.backend.service.UserService;
import io.dropwizard.lifecycle.Managed;
import jakarta.inject.Inject;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java
new file mode 100644
index 00000000..39e9e9a4
--- /dev/null
+++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java
@@ -0,0 +1,76 @@
+package com.commafeed.backend.service.db;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.manticore.h2.H2MigrationTool;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@RequiredArgsConstructor
+@Slf4j
+public class H2MigrationService {
+
+ public void migrateIfNeeded(Path path, String user, String password) {
+ int format;
+ try {
+ format = getH2FileFormat(path);
+ } catch (IOException e) {
+ throw new RuntimeException("could not detect H2 format", e);
+ }
+
+ if (format == 2) {
+ try {
+ migrate(path, user, password, "2.1.214", "2.2.224");
+ } catch (Exception e) {
+ throw new RuntimeException("could not migrate H2 to format 3", e);
+ }
+ }
+ }
+
+ public int getH2FileFormat(Path path) throws IOException {
+ try (BufferedReader reader = Files.newBufferedReader(path)) {
+ String headers = reader.readLine();
+
+ return Stream.of(headers.split(","))
+ .filter(h -> h.startsWith("format:"))
+ .map(h -> h.split(":")[1])
+ .map(Integer::parseInt)
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("could not find format in H2 file headers"));
+ }
+ }
+
+ private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception {
+ log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion);
+
+ Path scriptPath = path.resolveSibling("script-" + System.currentTimeMillis() + ".sql");
+ Path newVersionPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(toVersion) + ".mv.db");
+ Path oldVersionBackupPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(fromVersion) + ".backup");
+
+ H2MigrationTool.readDriverRecords();
+ new H2MigrationTool().migrate(fromVersion, toVersion, path.toAbsolutePath().toString(), user, password,
+ scriptPath.toAbsolutePath().toString(), "", "", false, false, "");
+ if (!Files.exists(newVersionPath)) {
+ throw new RuntimeException("H2 migration failed, new version file not found");
+ }
+
+ Files.move(path, oldVersionBackupPath);
+ Files.move(newVersionPath, path);
+ Files.delete(oldVersionBackupPath);
+ Files.delete(scriptPath);
+
+ log.info("migrated H2 database from format {} to format {}", fromVersion, toVersion);
+ }
+
+ private String getPatchVersion(String version) {
+ return StringUtils.substringAfterLast(version, ".");
+ }
+
+}
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java
index 98b82191..5508cbd7 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java
@@ -3,7 +3,7 @@ package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
-import com.commafeed.backend.service.DatabaseCleaningService;
+import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java
index 76328ef3..fad42a75 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java
@@ -5,7 +5,7 @@ import java.time.Instant;
import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
-import com.commafeed.backend.service.DatabaseCleaningService;
+import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java
index 82c12de1..0c19afe3 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java
@@ -4,7 +4,7 @@ import java.time.Instant;
import java.util.concurrent.TimeUnit;
import com.commafeed.CommaFeedConfiguration;
-import com.commafeed.backend.service.DatabaseCleaningService;
+import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java
index aa5731f9..7b2afcaa 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java
@@ -2,7 +2,7 @@ package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit;
-import com.commafeed.backend.service.DatabaseCleaningService;
+import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java
index 4405e49a..4606dad3 100644
--- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java
+++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java
@@ -2,7 +2,7 @@ package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit;
-import com.commafeed.backend.service.DatabaseCleaningService;
+import com.commafeed.backend.service.db.DatabaseCleaningService;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java
new file mode 100644
index 00000000..578cbc3e
--- /dev/null
+++ b/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java
@@ -0,0 +1,29 @@
+package com.commafeed.backend.service.db;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class H2MigrationServiceTest {
+
+ @TempDir
+ private Path root;
+
+ @Test
+ void testMigrateIfNeeded() throws IOException {
+ Path path = root.resolve("database.mv.db");
+ Files.copy(Objects.requireNonNull(getClass().getResourceAsStream("/h2-migration/database-v2.1.214.mv.db")), path);
+
+ H2MigrationService service = new H2MigrationService();
+ Assertions.assertEquals(2, service.getH2FileFormat(path));
+
+ service.migrateIfNeeded(path, "sa", "sa");
+ Assertions.assertEquals(3, service.getH2FileFormat(path));
+ }
+
+}
\ No newline at end of file
diff --git a/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db b/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db
new file mode 100644
index 00000000..b11490bc
Binary files /dev/null and b/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db differ