From 25011b72e2ac025bc733c6efd67708cea888f306 Mon Sep 17 00:00:00 2001 From: Yuki Yamazaki Date: Fri, 26 Sep 2025 23:05:34 +0900 Subject: [PATCH 1/2] feat: simplify database migration system for local-first application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unnecessary features (checksums, reversible migrations) - Rename methods for clarity (up→apply, upSql→sql) - Fix schema mismatches in V1 migration - Implement MigrationAwareDatabaseProvider pattern - Add transaction support for atomic migrations - Update native image configuration - Remove DatabaseMigrationBootstrapper - All tests passing (32 tests) --- .../ScopeManagementInfrastructureModule.kt | 58 +++- .../native-image/resource-config.json | 4 + ...mordant.terminal.TerminalInterfaceProvider | 1 - .../migration/DeviceSyncMigrationProvider.kt | 18 ++ .../sqldelight/SqlDelightDatabaseProvider.kt | 55 ++-- .../V1__Initial_device_sync_schema.sql | 25 ++ .../migration/EventStoreMigrationProvider.kt | 20 ++ .../sqldelight/SqlDelightDatabaseProvider.kt | 55 ++-- .../V1__Initial_event_store_schema.sql | 19 ++ .../ScopeManagementMigrationProvider.kt | 24 ++ .../sqldelight/SqlDelightDatabaseProvider.kt | 50 ++- .../V1__Initial_scope_management_schema.sql | 95 ++++++ platform/infrastructure/build.gradle.kts | 11 + .../database/migration/DatabaseIntegration.kt | 161 ++++++++++ .../database/migration/Migration.kt | 62 ++++ .../MigrationAwareDatabaseProvider.kt | 154 +++++++++ .../database/migration/MigrationConfig.kt | 8 + .../database/migration/MigrationDiscovery.kt | 64 ++++ .../database/migration/MigrationError.kt | 86 +++++ .../database/migration/MigrationExecutor.kt | 204 ++++++++++++ .../database/migration/MigrationManager.kt | 294 ++++++++++++++++++ .../migration/SchemaVersionRepository.kt | 248 +++++++++++++++ .../discovery/ResourceMigrationDiscovery.kt | 151 +++++++++ .../scopes/platform/db/SchemaVersion.sq | 71 +++++ .../migration/MigrationExecutorTest.kt | 134 ++++++++ .../MigrationManagerIntegrationTest.kt | 159 ++++++++++ 26 files changed, 2179 insertions(+), 52 deletions(-) delete mode 100644 apps/scopes/src/main/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider create mode 100644 contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/migration/DeviceSyncMigrationProvider.kt create mode 100644 contexts/device-synchronization/infrastructure/src/main/resources/migrations/device-sync/V1__Initial_device_sync_schema.sql create mode 100644 contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/migration/EventStoreMigrationProvider.kt create mode 100644 contexts/event-store/infrastructure/src/main/resources/migrations/event-store/V1__Initial_event_store_schema.sql create mode 100644 contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/migration/ScopeManagementMigrationProvider.kt create mode 100644 contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/DatabaseIntegration.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationConfig.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationDiscovery.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationError.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutor.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManager.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/SchemaVersionRepository.kt create mode 100644 platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/discovery/ResourceMigrationDiscovery.kt create mode 100644 platform/infrastructure/src/main/sqldelight/io/github/kamiazya/scopes/platform/db/SchemaVersion.sq create mode 100644 platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutorTest.kt create mode 100644 platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManagerIntegrationTest.kt diff --git a/apps/scopes/src/main/kotlin/io/github/kamiazya/scopes/apps/cli/di/scopemanagement/ScopeManagementInfrastructureModule.kt b/apps/scopes/src/main/kotlin/io/github/kamiazya/scopes/apps/cli/di/scopemanagement/ScopeManagementInfrastructureModule.kt index 898eaa114..59f9f8d39 100644 --- a/apps/scopes/src/main/kotlin/io/github/kamiazya/scopes/apps/cli/di/scopemanagement/ScopeManagementInfrastructureModule.kt +++ b/apps/scopes/src/main/kotlin/io/github/kamiazya/scopes/apps/cli/di/scopemanagement/ScopeManagementInfrastructureModule.kt @@ -1,6 +1,8 @@ package io.github.kamiazya.scopes.apps.cli.di.scopemanagement import io.github.kamiazya.scopes.platform.application.port.TransactionManager +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.MigrationAwareDatabaseProvider +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.MigrationConfig import io.github.kamiazya.scopes.platform.infrastructure.transaction.SqlDelightTransactionManager import io.github.kamiazya.scopes.scopemanagement.db.ScopeManagementDatabase import io.github.kamiazya.scopes.scopemanagement.domain.repository.ActiveContextRepository @@ -17,13 +19,13 @@ import io.github.kamiazya.scopes.scopemanagement.infrastructure.adapters.ErrorMa import io.github.kamiazya.scopes.scopemanagement.infrastructure.alias.generation.DefaultAliasGenerationService import io.github.kamiazya.scopes.scopemanagement.infrastructure.alias.generation.providers.DefaultWordProvider import io.github.kamiazya.scopes.scopemanagement.infrastructure.alias.generation.strategies.HaikunatorStrategy +import io.github.kamiazya.scopes.scopemanagement.infrastructure.migration.ScopeManagementMigrationProvider import io.github.kamiazya.scopes.scopemanagement.infrastructure.repository.SqlDelightActiveContextRepository import io.github.kamiazya.scopes.scopemanagement.infrastructure.repository.SqlDelightAspectDefinitionRepository import io.github.kamiazya.scopes.scopemanagement.infrastructure.repository.SqlDelightContextViewRepository import io.github.kamiazya.scopes.scopemanagement.infrastructure.repository.SqlDelightScopeAliasRepository import io.github.kamiazya.scopes.scopemanagement.infrastructure.repository.SqlDelightScopeRepository import io.github.kamiazya.scopes.scopemanagement.infrastructure.service.AspectQueryFilterValidator -import io.github.kamiazya.scopes.scopemanagement.infrastructure.sqldelight.SqlDelightDatabaseProvider import org.koin.core.qualifier.named import org.koin.dsl.module @@ -38,15 +40,59 @@ import org.koin.dsl.module * - Default services following Zero-Configuration principle */ val scopeManagementInfrastructureModule = module { - // SQLDelight Database + // Migration configuration + single(named("scopeManagementMigrationConfig")) { + MigrationConfig( + maxRetries = 3, + ) + } + + // Migration provider + single(named("scopeManagementMigrationProvider")) { + { ScopeManagementMigrationProvider(logger = get()).getMigrations() } + } + + // Migration-aware database provider + single>(named("scopeManagementDatabaseProvider")) { + val migrationProvider: () -> List = + get(named("scopeManagementMigrationProvider")) + val config: MigrationConfig = get(named("scopeManagementMigrationConfig")) + val logger: io.github.kamiazya.scopes.platform.observability.logging.Logger = get() + + MigrationAwareDatabaseProvider( + migrationProvider = migrationProvider, + config = config, + logger = logger, + databaseFactory = { driver -> + // Schema is created by migrations; avoid double-creation here + ScopeManagementDatabase(driver) + }, + ) + } + + // SQLDelight Database using the migration-aware provider single(named("scopeManagement")) { val databasePath: String = get(named("databasePath")) + val provider: MigrationAwareDatabaseProvider = get(named("scopeManagementDatabaseProvider")) + val dbPath = if (databasePath == ":memory:") { ":memory:" } else { "$databasePath/scope-management.db" } - SqlDelightDatabaseProvider.createDatabase(dbPath) + + // Create database with migrations applied + kotlinx.coroutines.runBlocking { + provider.createDatabase(dbPath).fold( + ifLeft = { err -> + // Fail-fast using Kotlin's error() function + error("Failed to create database: ${err.message}") + }, + ifRight = { database -> + database + }, + ) + } } // Repository implementations - mix of SQLDelight and legacy SQLite @@ -128,4 +174,10 @@ val scopeManagementInfrastructureModule = module { logger = get(), ) } + + // Note: Schema version repository and migration executor are no longer needed here + // They are handled internally by MigrationAwareDatabaseProvider + + // Note: Migrations are now automatically run when creating the ManagedSqlDriver + // No need for separate MigrationManager or DatabaseMigrationBootstrapper } diff --git a/apps/scopes/src/main/resources/META-INF/native-image/resource-config.json b/apps/scopes/src/main/resources/META-INF/native-image/resource-config.json index 89d0cb44c..421e9a331 100644 --- a/apps/scopes/src/main/resources/META-INF/native-image/resource-config.json +++ b/apps/scopes/src/main/resources/META-INF/native-image/resource-config.json @@ -20,6 +20,10 @@ "pattern" : "logback-.*\\.xml$" }, { "pattern" : "META-INF/services/.*" + }, { + "pattern" : "migrations/.*\\.sql$" + }, { + "pattern" : "migrations/.*/.*\\.sql$" } ] }, "bundles" : [ ] diff --git a/apps/scopes/src/main/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider b/apps/scopes/src/main/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider deleted file mode 100644 index 5aeb8c7b6..000000000 --- a/apps/scopes/src/main/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider +++ /dev/null @@ -1 +0,0 @@ -com.github.ajalt.mordant.terminal.terminalinterface.jna.TerminalInterfaceProviderJna diff --git a/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/migration/DeviceSyncMigrationProvider.kt b/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/migration/DeviceSyncMigrationProvider.kt new file mode 100644 index 000000000..c83f4f4cb --- /dev/null +++ b/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/migration/DeviceSyncMigrationProvider.kt @@ -0,0 +1,18 @@ +package io.github.kamiazya.scopes.devicesync.infrastructure.migration + +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.Migration +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.scanner.ResourceMigrationDiscovery +import io.github.kamiazya.scopes.platform.observability.logging.Logger + +/** + * Provides migrations for the Device Synchronization bounded context. + */ +class DeviceSyncMigrationProvider(private val logger: Logger) { + private val discovery = ResourceMigrationDiscovery( + resourcePath = "migrations/device-sync", + classLoader = this::class.java.classLoader, + logger = logger, + ) + + fun getMigrations(): List = discovery.discoverMigrations() +} diff --git a/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt b/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt index 1d36c9623..7ec1bef1f 100644 --- a/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt +++ b/contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt @@ -1,35 +1,50 @@ package io.github.kamiazya.scopes.devicesync.infrastructure.sqldelight -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import io.github.kamiazya.scopes.devicesync.db.DeviceSyncDatabase +import io.github.kamiazya.scopes.devicesync.infrastructure.migration.DeviceSyncMigrationProvider +import io.github.kamiazya.scopes.platform.infrastructure.database.ManagedSqlDriver +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.applyMigrations +import io.github.kamiazya.scopes.platform.observability.logging.ConsoleLogger +import kotlinx.coroutines.runBlocking /** - * Provides SQLDelight database instances for Device Synchronization. + * Provides SQLDelight database instances for Device Synchronization with automatic migrations. */ object SqlDelightDatabaseProvider { - /** - * Creates a new DeviceSyncDatabase instance. - */ - fun createDatabase(databasePath: String): DeviceSyncDatabase { - val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:$databasePath") - - // Create the database schema - DeviceSyncDatabase.Schema.create(driver) + class ManagedDatabase(private val database: DeviceSyncDatabase, private val managedDriver: AutoCloseable) : + DeviceSyncDatabase by database, + AutoCloseable { + override fun close() = managedDriver.close() + } - // Enable foreign keys - driver.execute(null, "PRAGMA foreign_keys=ON", 0) + fun createDatabase(databasePath: String): DeviceSyncDatabase { + val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath) + val driver = managedDriver.driver - return DeviceSyncDatabase(driver) + val logger = ConsoleLogger("DeviceSyncDB") + val migrations = DeviceSyncMigrationProvider(logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } + return ManagedDatabase(DeviceSyncDatabase(driver), managedDriver) } - /** - * Creates an in-memory database for testing. - */ fun createInMemoryDatabase(): DeviceSyncDatabase { - val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - DeviceSyncDatabase.Schema.create(driver) - return DeviceSyncDatabase(driver) + val managedDriver = ManagedSqlDriver(":memory:") + val driver = managedDriver.driver + + val logger = ConsoleLogger("DeviceSyncDB-InMemory") + val migrations = DeviceSyncMigrationProvider(logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } + return ManagedDatabase(DeviceSyncDatabase(driver), managedDriver) } } diff --git a/contexts/device-synchronization/infrastructure/src/main/resources/migrations/device-sync/V1__Initial_device_sync_schema.sql b/contexts/device-synchronization/infrastructure/src/main/resources/migrations/device-sync/V1__Initial_device_sync_schema.sql new file mode 100644 index 000000000..079d2e36d --- /dev/null +++ b/contexts/device-synchronization/infrastructure/src/main/resources/migrations/device-sync/V1__Initial_device_sync_schema.sql @@ -0,0 +1,25 @@ +-- Initial schema for Device Synchronization + +CREATE TABLE IF NOT EXISTS devices ( + device_id TEXT PRIMARY KEY NOT NULL, + last_sync_at INTEGER, + last_successful_push INTEGER, + last_successful_pull INTEGER, + sync_status TEXT NOT NULL DEFAULT 'NEVER_SYNCED', + pending_changes INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_devices_sync_status ON devices(sync_status); +CREATE INDEX IF NOT EXISTS idx_devices_updated_at ON devices(updated_at); + +CREATE TABLE IF NOT EXISTS vector_clocks ( + device_id TEXT NOT NULL, + component_device TEXT NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY (device_id, component_device) +); + +CREATE INDEX IF NOT EXISTS idx_vector_clocks_device_id ON vector_clocks(device_id); + diff --git a/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/migration/EventStoreMigrationProvider.kt b/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/migration/EventStoreMigrationProvider.kt new file mode 100644 index 000000000..9caf94a3b --- /dev/null +++ b/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/migration/EventStoreMigrationProvider.kt @@ -0,0 +1,20 @@ +package io.github.kamiazya.scopes.eventstore.infrastructure.migration + +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.Migration +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.scanner.ResourceMigrationDiscovery +import io.github.kamiazya.scopes.platform.observability.logging.Logger + +/** + * Provides migrations for the Event Store bounded context. + * + * Migrations are discovered from classpath resources under migrations/event-store. + */ +class EventStoreMigrationProvider(private val logger: Logger) { + private val discovery = ResourceMigrationDiscovery( + resourcePath = "migrations/event-store", + classLoader = this::class.java.classLoader, + logger = logger, + ) + + fun getMigrations(): List = discovery.discoverMigrations() +} diff --git a/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt b/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt index e91ff0a61..d9b7646a0 100644 --- a/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt +++ b/contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt @@ -1,35 +1,50 @@ package io.github.kamiazya.scopes.eventstore.infrastructure.sqldelight -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import io.github.kamiazya.scopes.eventstore.db.EventStoreDatabase +import io.github.kamiazya.scopes.eventstore.infrastructure.migration.EventStoreMigrationProvider +import io.github.kamiazya.scopes.platform.infrastructure.database.ManagedSqlDriver +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.applyMigrations +import io.github.kamiazya.scopes.platform.observability.logging.ConsoleLogger +import kotlinx.coroutines.runBlocking /** - * Provides SQLDelight database instances for Event Store. + * Provides SQLDelight database instances for Event Store with automatic migrations. */ object SqlDelightDatabaseProvider { - /** - * Creates a new EventStoreDatabase instance. - */ - fun createDatabase(databasePath: String): EventStoreDatabase { - val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:$databasePath") - - // Create the database schema - EventStoreDatabase.Schema.create(driver) + class ManagedDatabase(private val database: EventStoreDatabase, private val managedDriver: AutoCloseable) : + EventStoreDatabase by database, + AutoCloseable { + override fun close() = managedDriver.close() + } - // Enable foreign keys - driver.execute(null, "PRAGMA foreign_keys=ON", 0) + fun createDatabase(databasePath: String): EventStoreDatabase { + val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath) + val driver = managedDriver.driver - return EventStoreDatabase(driver) + val logger = ConsoleLogger("EventStoreDB") + val migrations = EventStoreMigrationProvider(logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } + return ManagedDatabase(EventStoreDatabase(driver), managedDriver) } - /** - * Creates an in-memory database for testing. - */ fun createInMemoryDatabase(): EventStoreDatabase { - val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - EventStoreDatabase.Schema.create(driver) - return EventStoreDatabase(driver) + val managedDriver = ManagedSqlDriver(":memory:") + val driver = managedDriver.driver + + val logger = ConsoleLogger("EventStoreDB-InMemory") + val migrations = EventStoreMigrationProvider(logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } + return ManagedDatabase(EventStoreDatabase(driver), managedDriver) } } diff --git a/contexts/event-store/infrastructure/src/main/resources/migrations/event-store/V1__Initial_event_store_schema.sql b/contexts/event-store/infrastructure/src/main/resources/migrations/event-store/V1__Initial_event_store_schema.sql new file mode 100644 index 000000000..1633a402f --- /dev/null +++ b/contexts/event-store/infrastructure/src/main/resources/migrations/event-store/V1__Initial_event_store_schema.sql @@ -0,0 +1,19 @@ +-- Initial schema for Event Store + +CREATE TABLE IF NOT EXISTS events ( + sequence_number INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + aggregate_id TEXT NOT NULL, + aggregate_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + event_data TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + stored_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_aggregate_id_version ON events(aggregate_id, aggregate_version); +CREATE INDEX IF NOT EXISTS idx_events_stored_at ON events(stored_at); +CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_events_event_type_sequence_number ON events(event_type, sequence_number); +CREATE INDEX IF NOT EXISTS idx_events_event_type_occurred_at ON events(event_type, occurred_at); + diff --git a/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/migration/ScopeManagementMigrationProvider.kt b/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/migration/ScopeManagementMigrationProvider.kt new file mode 100644 index 000000000..2dda59e55 --- /dev/null +++ b/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/migration/ScopeManagementMigrationProvider.kt @@ -0,0 +1,24 @@ +package io.github.kamiazya.scopes.scopemanagement.infrastructure.migration + +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.Migration +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.scanner.ResourceMigrationDiscovery +import io.github.kamiazya.scopes.platform.observability.logging.Logger + +/** + * Provides migrations for the Scope Management bounded context. + * + * Migrations are discovered from the classpath under the migrations directory. + * They follow the naming convention: V{version}__{description}.sql + */ +class ScopeManagementMigrationProvider(private val logger: Logger) { + private val discovery = ResourceMigrationDiscovery( + resourcePath = "migrations/scope-management", + classLoader = this::class.java.classLoader, + logger = logger, + ) + + /** + * Gets all migrations for the scope management context. + */ + fun getMigrations(): List = discovery.discoverMigrations() +} diff --git a/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt b/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt index 25c0d2b14..087c144e5 100644 --- a/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt +++ b/contexts/scope-management/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt @@ -1,13 +1,19 @@ package io.github.kamiazya.scopes.scopemanagement.infrastructure.sqldelight import io.github.kamiazya.scopes.platform.infrastructure.database.ManagedSqlDriver +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.MigrationAwareDatabaseProvider +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.MigrationConfig +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.applyMigrations +import io.github.kamiazya.scopes.platform.observability.logging.ConsoleLogger import io.github.kamiazya.scopes.scopemanagement.db.ScopeManagementDatabase +import io.github.kamiazya.scopes.scopemanagement.infrastructure.migration.ScopeManagementMigrationProvider +import kotlinx.coroutines.runBlocking /** * Provides SQLDelight database instances for Scope Management. * - * This provider creates databases with automatic resource management. - * The returned ManagedDatabase wrapper ensures proper cleanup on close. + * This provider now uses MigrationAwareDatabaseProvider to create and migrate the database, + * aligning runtime and test initialization with the same migration source of truth. */ object SqlDelightDatabaseProvider { @@ -22,27 +28,55 @@ object SqlDelightDatabaseProvider { } } + private fun provider(loggerName: String = "ScopeManagementDB"): MigrationAwareDatabaseProvider { + val logger = ConsoleLogger(loggerName) + val migrations = { ScopeManagementMigrationProvider(logger = logger).getMigrations() } + return MigrationAwareDatabaseProvider( + migrationProvider = migrations, + config = MigrationConfig(maxRetries = 3), + logger = logger, + databaseFactory = { driver -> ScopeManagementDatabase(driver) }, + ) + } + /** * Creates a new ScopeManagementDatabase instance with automatic resource management. + * Applies all pending migrations on the same driver before returning the database. */ fun createDatabase(databasePath: String): ScopeManagementDatabase { val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath) val driver = managedDriver.driver - // Create the database schema - ScopeManagementDatabase.Schema.create(driver) + val logger = ConsoleLogger("ScopeManagementDB") + val migrations = ScopeManagementMigrationProvider(logger = logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } - return ManagedDatabase(ScopeManagementDatabase(driver), managedDriver) + val db = ScopeManagementDatabase(driver) + return ManagedDatabase(db, managedDriver) } /** - * Creates an in-memory database for testing. + * Creates an in-memory database for testing with migrations applied. */ fun createInMemoryDatabase(): ScopeManagementDatabase { val managedDriver = ManagedSqlDriver(":memory:") val driver = managedDriver.driver - ScopeManagementDatabase.Schema.create(driver) - return ManagedDatabase(ScopeManagementDatabase(driver), managedDriver) + val logger = ConsoleLogger("ScopeManagementDB-InMemory") + val migrations = ScopeManagementMigrationProvider(logger = logger).getMigrations() + runBlocking { + driver.applyMigrations(migrations, logger).fold( + ifLeft = { err -> error("Migration failed: ${err.message}") }, + ifRight = { }, + ) + } + + val db = ScopeManagementDatabase(driver) + return ManagedDatabase(db, managedDriver) } } diff --git a/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql b/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql new file mode 100644 index 000000000..49aa9275d --- /dev/null +++ b/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql @@ -0,0 +1,95 @@ +-- Initial schema for Scope Management bounded context +-- This migration creates all the tables and indexes for the scope management system + +-- Scopes table +CREATE TABLE IF NOT EXISTS scopes ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + description TEXT, + parent_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES scopes(id) ON DELETE CASCADE +); + +-- Scopes indexes +CREATE INDEX IF NOT EXISTS idx_scopes_parent_id ON scopes(parent_id); +CREATE INDEX IF NOT EXISTS idx_scopes_created_at ON scopes(created_at); +CREATE INDEX IF NOT EXISTS idx_scopes_updated_at ON scopes(updated_at); +CREATE INDEX IF NOT EXISTS idx_scopes_title_parent ON scopes(title, parent_id); +CREATE INDEX IF NOT EXISTS idx_scopes_parent_created ON scopes(parent_id, created_at, id); +CREATE INDEX IF NOT EXISTS idx_scopes_root_created ON scopes(created_at, id) WHERE parent_id IS NULL; + +-- Scope aliases table +CREATE TABLE IF NOT EXISTS scope_aliases ( + id TEXT PRIMARY KEY NOT NULL, + scope_id TEXT NOT NULL, + alias_name TEXT NOT NULL UNIQUE, + alias_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (scope_id) REFERENCES scopes(id) ON DELETE CASCADE +); + +-- Scope aliases indexes +CREATE INDEX IF NOT EXISTS idx_scope_aliases_scope_id ON scope_aliases(scope_id); +CREATE INDEX IF NOT EXISTS idx_scope_aliases_alias_name ON scope_aliases(alias_name); +CREATE INDEX IF NOT EXISTS idx_scope_aliases_alias_type ON scope_aliases(alias_type); + +-- Scope aspects table (align with SQLDelight schema) +CREATE TABLE IF NOT EXISTS scope_aspects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_id TEXT NOT NULL, + aspect_key TEXT NOT NULL, + aspect_value TEXT NOT NULL, + FOREIGN KEY (scope_id) REFERENCES scopes(id) ON DELETE CASCADE, + UNIQUE(scope_id, aspect_key, aspect_value) +); + +-- Scope aspects indexes +CREATE INDEX IF NOT EXISTS idx_scope_aspects_scope_id ON scope_aspects(scope_id); +CREATE INDEX IF NOT EXISTS idx_scope_aspects_aspect_key ON scope_aspects(aspect_key); +CREATE INDEX IF NOT EXISTS idx_scope_aspects_key_value ON scope_aspects(aspect_key, aspect_value); + +-- Context views table +CREATE TABLE IF NOT EXISTS context_views ( + id TEXT PRIMARY KEY NOT NULL, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT, + filter TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Context views indexes +CREATE INDEX IF NOT EXISTS idx_context_views_key ON context_views(key); +CREATE INDEX IF NOT EXISTS idx_context_views_name ON context_views(name); +CREATE INDEX IF NOT EXISTS idx_context_views_created_at ON context_views(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_context_views_updated_at ON context_views(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_context_views_key_filter ON context_views(key, filter) WHERE filter IS NOT NULL; + +-- Aspect definitions table (align with SQLDelight schema) +CREATE TABLE IF NOT EXISTS aspect_definitions ( + key TEXT PRIMARY KEY NOT NULL, + aspect_type TEXT NOT NULL, + description TEXT, + allow_multiple_values INTEGER NOT NULL DEFAULT 0 +); + +-- Aspect definitions indexes +CREATE INDEX IF NOT EXISTS idx_aspect_definitions_aspect_type ON aspect_definitions(aspect_type); + +-- Active context table (single row table for current context) +CREATE TABLE IF NOT EXISTS active_context ( + id TEXT PRIMARY KEY NOT NULL DEFAULT 'default', + context_view_id TEXT, + updated_at INTEGER NOT NULL, + FOREIGN KEY (context_view_id) REFERENCES context_views(id) ON DELETE SET NULL +); + +-- Ensure only one active context exists +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_context_single ON active_context(id); + +-- Performance index for foreign key lookup +CREATE INDEX IF NOT EXISTS idx_active_context_view_id ON active_context(context_view_id) WHERE context_view_id IS NOT NULL; diff --git a/platform/infrastructure/build.gradle.kts b/platform/infrastructure/build.gradle.kts index 9fc31f3dd..3ff581728 100644 --- a/platform/infrastructure/build.gradle.kts +++ b/platform/infrastructure/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") + alias(libs.plugins.sqldelight) } dependencies { @@ -24,8 +25,18 @@ dependencies { // Testing testImplementation(libs.bundles.kotest) testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } tasks.withType { useJUnitPlatform() } + +sqldelight { + databases { + create("PlatformDatabase") { + packageName.set("io.github.kamiazya.scopes.platform.db") + dialect(libs.sqldelight.dialect.sqlite) + } + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/DatabaseIntegration.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/DatabaseIntegration.kt new file mode 100644 index 000000000..8efe84fc8 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/DatabaseIntegration.kt @@ -0,0 +1,161 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import app.cash.sqldelight.db.SqlDriver +import arrow.core.Either +import arrow.core.raise.either +import io.github.kamiazya.scopes.platform.db.PlatformDatabase +import io.github.kamiazya.scopes.platform.observability.logging.Logger + +/** + * Integration utilities for adding migration support to existing database providers. + * + * This provides extension functions and utilities to add migration capabilities + * to existing SQLDelight database providers without breaking existing code. + */ +object DatabaseIntegration { + + /** + * Applies migrations to an existing SQLite driver before database creation. + * + * @param driver The SQL driver to apply migrations to + * @param migrations List of available migrations + * @param logger Logger for migration progress + * @return Either an error or migration result + */ + suspend fun applyMigrations(driver: SqlDriver, migrations: List, logger: Logger): Either = either { + logger.debug("Applying migrations to database") + + val platformDatabase = PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = { migrations }, + ) + + // Ensure schema_versions table exists + executor.ensureSchemaVersionsTable().bind() + + // Apply all pending migrations + val result = migrationManager.migrateUp().bind() + + if (result.executedMigrations.isNotEmpty()) { + logger.info( + "Applied ${result.executedMigrations.size} migrations " + + "in ${result.totalExecutionTime}ms", + ) + } else { + logger.debug("Database is already up to date") + } + + result + } + + /** + * Validates the migration state of an existing database. + * + * @param driver The SQL driver to validate + * @param migrations List of available migrations + * @param logger Logger for validation messages + * @return Either an error or validation result + */ + suspend fun validateMigrations(driver: SqlDriver, migrations: List, logger: Logger): Either = either { + logger.debug("Validating migration state") + + val platformDatabase = PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = { migrations }, + ) + + // Ensure schema_versions table exists first + executor.ensureSchemaVersionsTable().bind() + + val result = migrationManager.validate(repair = false).bind() + + if (!result.isValid) { + logger.warn("Migration validation failed: ${result.inconsistencies}") + } else { + logger.debug("Migration state is valid") + } + + result + } + + /** + * Gets the current migration status of a database. + * + * @param driver The SQL driver to check + * @param migrations List of available migrations + * @return Either an error or migration status + */ + suspend fun getMigrationStatus(driver: SqlDriver, migrations: List): Either = either { + val platformDatabase = PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = { migrations }, + ) + + // Ensure schema_versions table exists first + executor.ensureSchemaVersionsTable().bind() + + migrationManager.getStatus().bind() + } + + /** + * Creates a migration manager for an existing database driver. + * + * @param driver The SQL driver to create manager for + * @param migrations List of available migrations + * @return Either an error or migration manager + */ + suspend fun createMigrationManager(driver: SqlDriver, migrations: List): Either = either { + val platformDatabase = PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + + // Ensure schema_versions table exists first + executor.ensureSchemaVersionsTable().bind() + + DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = { migrations }, + ) + } +} + +/** + * Extension functions for SqlDriver to add migration capabilities. + */ + +/** + * Apply migrations to this SQL driver. + */ +suspend fun SqlDriver.applyMigrations(migrations: List, logger: Logger): Either = + DatabaseIntegration.applyMigrations(this, migrations, logger) + +/** + * Validate migrations on this SQL driver. + */ +suspend fun SqlDriver.validateMigrations(migrations: List, logger: Logger): Either = + DatabaseIntegration.validateMigrations(this, migrations, logger) + +/** + * Get migration status for this SQL driver. + */ +suspend fun SqlDriver.getMigrationStatus(migrations: List): Either = + DatabaseIntegration.getMigrationStatus(this, migrations) + +/** + * Create a migration manager for this SQL driver. + */ +suspend fun SqlDriver.createMigrationManager(migrations: List): Either = + DatabaseIntegration.createMigrationManager(this, migrations) diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt new file mode 100644 index 000000000..ca59e04bc --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt @@ -0,0 +1,62 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import arrow.core.Either +import io.github.kamiazya.scopes.platform.commons.time.Instant +import kotlinx.datetime.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Represents a database schema migration. + * + * Each migration is uniquely identified by its version and can be applied + * in ascending order to upgrade the database schema. + */ +interface Migration { + /** + * The version number of this migration. + * Versions should be sequential and unique. + */ + val version: Long + + /** + * Human-readable description of what this migration does. + */ + val description: String + + /** + * Apply this migration to the database. + * + * @param executor The migration executor that provides database access + * @return Either an error or Unit on success + */ + suspend fun apply(executor: MigrationExecutor): Either +} + +/** + * Abstract base class for SQL-based migrations. + * Provides common functionality for most database schema changes. + */ +abstract class SqlMigration(override val version: Long, override val description: String) : Migration { + + /** + * SQL statements to execute when applying this migration. + */ + abstract val sql: List + + override suspend fun apply(executor: MigrationExecutor): Either = executor.executeSql(sql) +} + +/** + * Represents an applied migration record stored in the database. + */ +data class AppliedMigration(val version: Long, val description: String, val appliedAt: Instant, val executionTime: Duration) { + companion object { + fun from(migration: Migration, appliedAt: Instant = Clock.System.now(), executionTimeMs: Long): AppliedMigration = AppliedMigration( + version = migration.version, + description = migration.description, + appliedAt = appliedAt, + executionTime = executionTimeMs.milliseconds, + ) + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt new file mode 100644 index 000000000..d63925669 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt @@ -0,0 +1,154 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import app.cash.sqldelight.db.SqlDriver +import arrow.core.Either +import arrow.core.raise.either +import io.github.kamiazya.scopes.platform.infrastructure.database.ManagedSqlDriver +import io.github.kamiazya.scopes.platform.observability.logging.Logger +import kotlinx.datetime.Clock + +/** + * A generic database provider that automatically handles schema migrations for any SQLDelight database type. + * + * This provider wraps the standard SqlDelightDatabaseProvider functionality + * with automatic migration support. It can validate the schema on startup, + * apply pending migrations, and ensure database consistency. + * + * @param T The type of the SQLDelight database (e.g., ScopeManagementDatabase) + * @param migrationProvider Function that provides the list of migrations to apply + * @param config Migration configuration options + * @param logger Logger for migration progress + * @param databaseFactory Factory function to create the database instance from a SqlDriver + */ +class MigrationAwareDatabaseProvider( + private val migrationProvider: () -> List, + private val config: MigrationConfig = MigrationConfig(), + private val logger: Logger, + private val databaseFactory: (SqlDriver) -> T, + private val clock: Clock = Clock.System, +) { + + /** + * Creates a database with automatic migration support. + * + * @param databasePath Path to the SQLite database file + * @return Either an error or database initialization result + */ + suspend fun createDatabase(databasePath: String): Either = either { + logger.info("Initializing migration-aware database at: $databasePath") + + // Create the managed driver with default SQLite settings + val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath) + val driver = managedDriver.driver + + // Explicitly apply PRAGMA settings to ensure they are set + // This is redundant with connection properties but ensures settings are applied + driver.execute(null, "PRAGMA foreign_keys = ON", 0) + driver.execute(null, "PRAGMA journal_mode = WAL", 0) + driver.execute(null, "PRAGMA synchronous = NORMAL", 0) + driver.execute(null, "PRAGMA cache_size = -64000", 0) + driver.execute(null, "PRAGMA temp_store = MEMORY", 0) + + // Initialize migration components + val platformDatabase = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = migrationProvider, + clock = clock, + ) + + // Ensure schema_versions table exists + executor.ensureSchemaVersionsTable().bind() + + // Always apply pending migrations + logger.debug("Checking for pending migrations") + val status = migrationManager.getStatus().bind() + + if (!status.isUpToDate) { + logger.info("Applying ${status.pendingMigrations.size} pending migrations") + + val migrationResult = migrationManager.migrateUp().bind() + + logger.info( + "Applied ${migrationResult.executedMigrations.size} migrations " + + "in ${migrationResult.totalExecutionTime.inWholeMilliseconds}ms " + + "(${migrationResult.fromVersion} -> ${migrationResult.toVersion})", + ) + } else { + logger.debug("Database is up to date (version ${status.currentVersion})") + } + + // Create the specific database instance + val database = databaseFactory(driver) + logger.info("Database initialized successfully") + + database + } + + /** + * Creates an in-memory database for testing with migration support. + * + * @return Either an error or the database instance + */ + suspend fun createInMemoryDatabase(): Either = either { + logger.debug("Creating in-memory migration-aware database") + + val managedDriver = ManagedSqlDriver(":memory:") + val driver = managedDriver.driver + + // Apply PRAGMA settings for in-memory database + driver.execute(null, "PRAGMA foreign_keys = ON", 0) + driver.execute(null, "PRAGMA synchronous = OFF", 0) // OFF is safe for in-memory + driver.execute(null, "PRAGMA temp_store = MEMORY", 0) + + // Initialize migration components + val platformDatabase = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = migrationProvider, + clock = clock, + ) + + // Ensure schema_versions table exists + executor.ensureSchemaVersionsTable().bind() + + // Always migrate in-memory databases to latest + logger.debug("Applying all migrations to in-memory database") + val migrationResult = migrationManager.migrateUp().bind() + + logger.info( + "Applied ${migrationResult.executedMigrations.size} migrations to in-memory database", + ) + + // Create the specific database instance + val database = databaseFactory(driver) + logger.debug("In-memory database initialized successfully") + + database + } + + /** + * Creates a database manager for an existing database connection. + * + * @param driver The existing SQL driver + * @return Either an error or migration manager + */ + fun createMigrationManager(driver: SqlDriver): Either = either { + val platformDatabase = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(platformDatabase) + + DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = migrationProvider, + clock = clock, + ) + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationConfig.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationConfig.kt new file mode 100644 index 000000000..f650c0ac0 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationConfig.kt @@ -0,0 +1,8 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +/** + * Configuration options for database migration operations. + * + * @property maxRetries Maximum number of retry attempts for failed migrations + */ +data class MigrationConfig(val maxRetries: Int = 3) diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationDiscovery.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationDiscovery.kt new file mode 100644 index 000000000..ffb98e92d --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationDiscovery.kt @@ -0,0 +1,64 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import arrow.core.Either + +/** + * Result of migration discovery operation. + */ +data class DiscoveryReport( + val migrations: List, + val discoveredFiles: Int, + val validMigrations: Int, + val invalidMigrations: List, + val duplicateVersions: List, +) + +/** + * Validation error for discovered migrations. + */ +data class ValidationError(val file: String, val version: Long?, val reason: String, val cause: Throwable? = null) + +/** + * Service for discovering and validating database migration files. + * + * This service scans specified directories for migration files, + * validates their format and content, and returns a list of + * executable Migration objects. + */ +interface MigrationDiscovery { + + /** + * Discover migrations from specified directories. + * + * @param searchPaths List of directories to search for migrations + * @param recursive Whether to search subdirectories recursively + * @return Either an error or discovery result + */ + suspend fun discoverMigrations(searchPaths: List, recursive: Boolean = true): Either + + /** + * Discover migrations from a single directory. + * + * @param searchPath Directory to search for migrations + * @param recursive Whether to search subdirectories recursively + * @return Either an error or discovery result + */ + suspend fun discoverMigrations(searchPath: String, recursive: Boolean = true): Either = + discoverMigrations(listOf(searchPath), recursive) + + /** + * Load and validate a specific migration file. + * + * @param filePath Path to the migration file + * @return Either an error or the loaded migration + */ + suspend fun loadMigration(filePath: String): Either + + /** + * Validate the discovered migrations for consistency. + * + * @param migrations List of migrations to validate + * @return Either an error or validation result + */ + suspend fun validateMigrations(migrations: List): Either> +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationError.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationError.kt new file mode 100644 index 000000000..0b7bb3a0a --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationError.kt @@ -0,0 +1,86 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +/** + * Represents errors that can occur during database migration operations. + */ +sealed class MigrationError : Exception() { + + /** + * Migration file could not be found or loaded. + */ + data class MigrationNotFound(val version: Long, val location: String) : MigrationError() { + override val message = "Migration version $version not found at: $location" + } + + /** + * Migration version is out of order or conflicts with existing migrations. + */ + data class VersionConflict(val version: Long, val conflictingVersion: Long, val reason: String) : MigrationError() { + override val message = "Migration version $version conflicts with version $conflictingVersion: $reason" + } + + /** + * SQL execution failed during migration. + */ + data class SqlExecutionError(val version: Long, val sql: String, override val cause: Throwable) : MigrationError() { + override val message = "SQL execution failed for migration version $version: ${cause.message}" + } + + /** + * Database is in an inconsistent state and requires manual intervention. + */ + data class CorruptedState(val reason: String, val suggestedAction: String) : MigrationError() { + override val message = "Database migration state is corrupted: $reason. " + + "Suggested action: $suggestedAction" + } + + /** + * Migration validation failed before execution. + */ + data class ValidationError(val version: Long, val validationIssue: String) : MigrationError() { + override val message = "Migration version $version failed validation: $validationIssue" + } + + /** + * Target version for rollback doesn't exist or is invalid. + */ + data class InvalidTargetVersion(val targetVersion: Long, val currentVersion: Long, val reason: String) : MigrationError() { + override val message = "Cannot migrate to version $targetVersion from $currentVersion: $reason" + } + + /** + * Generic database access error during migration. + */ + data class DatabaseError(val operation: String, override val cause: Throwable) : MigrationError() { + override val message = "Database error during $operation: ${cause.message}" + } + + /** + * Migration execution failed. + */ + data class MigrationFailed(val version: Long, override val message: String, override val cause: Throwable? = null) : MigrationError() + + /** + * No migrations were found. + */ + object NoMigrationsFound : MigrationError() { + override val message = "No migrations found" + } + + /** + * Schema is corrupted. + */ + data class SchemaCorrupted(override val message: String, override val cause: Throwable? = null) : MigrationError() + + /** + * Invalid migration structure or content. + */ + data class InvalidMigration(val version: Long, override val message: String, override val cause: Throwable? = null) : MigrationError() + + /** + * Version not found in applied migrations. + */ + data class VersionNotFound(val version: Long) : MigrationError() { + override val message = "Migration version $version not found in applied migrations" + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutor.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutor.kt new file mode 100644 index 000000000..187784f7c --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutor.kt @@ -0,0 +1,204 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import arrow.core.Either + +/** + * Interface for executing database migrations. + * + * Provides low-level database access for migration operations with + * transaction management and error handling. + */ +interface MigrationExecutor { + + /** + * Execute a list of SQL statements within a transaction. + * All statements must succeed or the entire transaction is rolled back. + * + * @param sqlStatements List of SQL statements to execute + * @return Either an error or Unit on success + */ + suspend fun executeSql(sqlStatements: List): Either + + /** + * Execute a single SQL statement. + * + * @param sql The SQL statement to execute + * @return Either an error or Unit on success + */ + suspend fun executeSql(sql: String): Either = executeSql(listOf(sql)) + + /** + * Check if a table exists in the database. + * + * @param tableName The name of the table to check + * @return Either an error or true if table exists, false otherwise + */ + suspend fun tableExists(tableName: String): Either + + /** + * Get the current database schema version. + * Returns 0 if no migrations have been applied. + * + * @return Either an error or the current version number + */ + suspend fun getCurrentVersion(): Either + + /** + * Check if the schema_versions table exists and create it if necessary. + * This is called automatically before any migration operations. + * + * @return Either an error or Unit on success + */ + suspend fun ensureSchemaVersionsTable(): Either + + /** + * Validate the database connection and basic operations. + * + * @return Either an error or Unit if database is accessible + */ + suspend fun validateConnection(): Either +} + +/** + * SQLDelight-based implementation of MigrationExecutor. + * + * Provides transaction management and error handling for SQLite databases + * using the existing SQLDelight infrastructure. + */ +class SqlDelightMigrationExecutor(private val driver: SqlDriver) : MigrationExecutor { + + override suspend fun executeSql(sqlStatements: List): Either { + if (sqlStatements.isEmpty()) return Either.Right(Unit) + + // Fast-path: single non-empty statement; let SQLite handle atomicity + if (sqlStatements.size == 1) { + val sql = sqlStatements.first().trim() + if (sql.isEmpty()) return Either.Right(Unit) + return try { + driver.execute(null, sql, 0) + Either.Right(Unit) + } catch (e: Exception) { + Either.Left( + MigrationError.SqlExecutionError( + version = 0, + sql = sql, + cause = e, + ), + ) + } + } + + // Multiple statements: execute atomically using SQLDelight Transacter + val db = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) + var currentSql: String = "" + return try { + db.transaction(noEnclosing = true) { + for (stmt in sqlStatements) { + val s = stmt.trim() + if (s.isEmpty()) continue + currentSql = s + driver.execute(null, s, 0) + } + } + Either.Right(Unit) + } catch (e: Exception) { + Either.Left( + MigrationError.SqlExecutionError( + version = 0, + sql = currentSql.ifEmpty { "" }, + cause = e, + ), + ) + } + } + + override suspend fun tableExists(tableName: String): Either = try { + val query = """ + SELECT COUNT(*) as count + FROM sqlite_master + WHERE type='table' AND name=? + """.trimIndent() + + var exists = false + driver.executeQuery(null, query, { cursor -> + if (cursor.next().value) { + exists = cursor.getLong(0)?.let { it > 0 } ?: false + } + QueryResult.Value(Unit) + }, 1) { + bindString(0, tableName) + } + + Either.Right(exists) + } catch (e: Exception) { + Either.Left( + MigrationError.DatabaseError( + operation = "check table existence", + cause = e, + ), + ) + } + + override suspend fun getCurrentVersion(): Either = ensureSchemaVersionsTable().fold( + ifLeft = { Either.Left(it) }, + ifRight = { + try { + val query = "SELECT COALESCE(MAX(version), 0) as version FROM schema_versions" + var version = 0L + + driver.executeQuery(null, query, { cursor -> + if (cursor.next().value) { + version = cursor.getLong(0) ?: 0L + } + QueryResult.Value(Unit) + }, 0) + + Either.Right(version) + } catch (e: Exception) { + Either.Left( + MigrationError.DatabaseError( + operation = "get current version", + cause = e, + ), + ) + } + }, + ) + + override suspend fun ensureSchemaVersionsTable(): Either { + val createTableSql = """ + CREATE TABLE IF NOT EXISTS schema_versions ( + version INTEGER PRIMARY KEY NOT NULL, + description TEXT NOT NULL, + applied_at INTEGER NOT NULL, + execution_time_ms INTEGER NOT NULL + ) + """.trimIndent() + + return try { + driver.execute(null, createTableSql, 0) + Either.Right(Unit) + } catch (e: Exception) { + Either.Left( + MigrationError.DatabaseError( + operation = "create schema_versions table", + cause = e, + ), + ) + } + } + + override suspend fun validateConnection(): Either = try { + driver.executeQuery(null, "SELECT 1", { QueryResult.Value(Unit) }, 0) + Either.Right(Unit) + } catch (e: Exception) { + Either.Left( + MigrationError.DatabaseError( + operation = "validate connection", + cause = e, + ), + ) + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManager.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManager.kt new file mode 100644 index 000000000..27319c8c0 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManager.kt @@ -0,0 +1,294 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration + +/** + * Result of migration execution operations. + */ +data class MigrationSummary(val executedMigrations: List, val totalExecutionTime: Duration, val fromVersion: Long, val toVersion: Long) + +/** + * Result of migration status check. + */ +data class MigrationStatusReport( + val currentVersion: Long, + val availableMigrations: List, + val appliedMigrations: List, + val pendingMigrations: List, + val isUpToDate: Boolean, + val hasGaps: Boolean, + val inconsistencies: List, +) { + val hasPendingMigrations: Boolean + get() = pendingMigrations.isNotEmpty() + + val latestVersion: Long? + get() = availableMigrations.maxOfOrNull { it.version } +} + +/** + * Central coordinator for database migration operations. + * + * Orchestrates the migration process by coordinating between migration discovery, + * validation, execution, and tracking. Provides high-level operations for + * applying migrations, rolling back, and checking status. + */ +interface MigrationManager { + + /** + * Get current migration status. + * + * @return Either an error or current migration status + */ + suspend fun getStatus(): Either + + /** + * Apply all pending migrations up to the latest available. + * + * @return Either an error or migration result + */ + suspend fun migrateUp(): Either + + /** + * Apply migrations up to a specific version. + * + * @param targetVersion The version to migrate to + * @return Either an error or migration result + */ + suspend fun migrateTo(targetVersion: Long): Either + + /** + * Validate the current migration state and fix any inconsistencies. + * + * @param repair Whether to attempt automatic repair of inconsistencies + * @return Either an error or validation result + */ + suspend fun validate(repair: Boolean = false): Either + + /** + * Force mark a migration as applied without executing it. + * USE WITH EXTREME CAUTION - only for manual database repairs. + * + * @param migration The migration to mark as applied + * @return Either an error or Unit on success + */ + suspend fun markAsApplied(migration: Migration): Either +} + +/** + * Default implementation of MigrationManager. + * + * Coordinates migration operations using provided executor, repository, and discovery services. + */ +class DefaultMigrationManager( + private val executor: MigrationExecutor, + private val repository: SchemaVersionStore, + private val migrationProvider: () -> List, + private val clock: Clock = Clock.System, +) : MigrationManager { + + override suspend fun getStatus(): Either = withContext(Dispatchers.IO) { + either { + val currentVersion = repository.getCurrentVersion().bind() + val availableMigrations = migrationProvider().sortedBy { it.version } + val appliedMigrations = repository.getAllAppliedMigrations().bind() + val appliedVersions = appliedMigrations.map { it.version }.toSet() + + val pendingMigrations = availableMigrations.filter { it.version !in appliedVersions } + val isUpToDate = pendingMigrations.isEmpty() + + val validationResult = repository.validateMigrationSequence().bind() + + MigrationStatusReport( + currentVersion = currentVersion, + availableMigrations = availableMigrations, + appliedMigrations = appliedMigrations, + pendingMigrations = pendingMigrations, + isUpToDate = isUpToDate, + hasGaps = !validationResult.isValid, + inconsistencies = validationResult.inconsistencies, + ) + } + } + + override suspend fun migrateUp(): Either = withContext(Dispatchers.IO) { + either { + val status = getStatus().bind() + + if (status.isUpToDate) { + MigrationSummary( + executedMigrations = emptyList(), + totalExecutionTime = Duration.ZERO, + fromVersion = status.currentVersion, + toVersion = status.currentVersion, + ) + } else { + executeMigrations(status.pendingMigrations, status.currentVersion) + } + } + } + + override suspend fun migrateTo(targetVersion: Long): Either = withContext(Dispatchers.IO) { + either { + val status = getStatus().bind() + val currentVersion = status.currentVersion + + when { + targetVersion == currentVersion -> { + MigrationSummary( + executedMigrations = emptyList(), + totalExecutionTime = Duration.ZERO, + fromVersion = currentVersion, + toVersion = currentVersion, + ) + } + targetVersion > currentVersion -> { + // Migrate up to target version + val migrationsToApply = status.pendingMigrations + .filter { it.version <= targetVersion } + .sortedBy { it.version } + + if (migrationsToApply.isEmpty()) { + raise( + MigrationError.InvalidTargetVersion( + targetVersion, + currentVersion, + "No migrations available to reach target version", + ), + ) + } + + executeMigrations(migrationsToApply, currentVersion) + } + else -> { + raise( + MigrationError.InvalidTargetVersion( + targetVersion, + currentVersion, + "Cannot rollback. Target version must be greater than or equal to current version", + ), + ) + } + } + } + } + + override suspend fun validate(repair: Boolean): Either = withContext(Dispatchers.IO) { + either { + // First perform basic validation + val validationResult = repository.validateMigrationSequence().bind() + + if (validationResult.isValid || !repair) { + validationResult + } else { + // If repair is requested and there are issues, attempt to fix them + val availableMigrations = migrationProvider().sortedBy { it.version } + val appliedMigrations = repository.getAllAppliedMigrations().bind() + + val inconsistencies = mutableListOf() + + // Check for migrations that have been applied but are no longer available + for (appliedMigration in appliedMigrations) { + val availableMigration = availableMigrations.find { it.version == appliedMigration.version } + if (availableMigration == null) { + inconsistencies.add("Applied migration ${appliedMigration.version} is no longer available") + continue + } + } + + SequenceValidationReport( + isValid = inconsistencies.isEmpty(), + gaps = validationResult.gaps, + inconsistencies = inconsistencies, + ) + } + } + } + + override suspend fun markAsApplied(migration: Migration): Either = withContext(Dispatchers.IO) { + either { + val appliedMigration = AppliedMigration( + version = migration.version, + description = migration.description, + appliedAt = clock.now(), + executionTime = Duration.ZERO, // Marked as applied, no execution time + ) + + repository.saveAppliedMigration(appliedMigration).bind() + } + } + + /** + * Execute a list of migrations in order. + */ + private suspend fun Raise.executeMigrations(migrations: List, fromVersion: Long): MigrationSummary { + val executedMigrations = mutableListOf() + var totalExecutionTime = Duration.ZERO + + for (migration in migrations) { + val startTime = clock.now() + + try { + // Validate migration before execution + val existingMigration = repository.findByVersion(migration.version).bind() + if (existingMigration != null) { + // Skip already applied migration + continue + } + + // Execute the migration + migration.apply(executor).bind() + + val endTime = clock.now() + val executionTime = (endTime - startTime) + + // Record the applied migration + val appliedMigration = AppliedMigration( + version = migration.version, + description = migration.description, + appliedAt = endTime, + executionTime = executionTime, + ) + + repository.saveAppliedMigration(appliedMigration).bind() + executedMigrations.add(appliedMigration) + totalExecutionTime += executionTime + } catch (e: MigrationError) { + // Re-raise migration errors directly + raise(e) + } catch (e: CancellationException) { + // Re-throw cancellation exceptions to preserve coroutine cancellation + throw e + } catch (e: Exception) { + // Wrap other exceptions + raise( + MigrationError.SqlExecutionError( + migration.version, + "Migration execution failed", + e, + ), + ) + } + } + + val toVersion = if (executedMigrations.isNotEmpty()) { + executedMigrations.last().version + } else { + fromVersion + } + + return MigrationSummary( + executedMigrations = executedMigrations, + totalExecutionTime = totalExecutionTime, + fromVersion = fromVersion, + toVersion = toVersion, + ) + } +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/SchemaVersionRepository.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/SchemaVersionRepository.kt new file mode 100644 index 000000000..f15ef4623 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/SchemaVersionRepository.kt @@ -0,0 +1,248 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import io.github.kamiazya.scopes.platform.commons.time.Instant +import io.github.kamiazya.scopes.platform.db.PlatformDatabase +import io.github.kamiazya.scopes.platform.db.Schema_versions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Repository interface for managing applied migration records. + */ +interface SchemaVersionStore { + + /** + * Save a record of an applied migration. + * + * @param migration The applied migration record to save + * @return Either an error or Unit on success + */ + suspend fun saveAppliedMigration(migration: AppliedMigration): Either + + /** + * Get all applied migrations ordered by version. + * + * @return Either an error or list of applied migrations + */ + suspend fun getAllAppliedMigrations(): Either> + + /** + * Get applied migrations with pagination. + * + * @param limit Maximum number of migrations to return + * @param offset Number of migrations to skip + * @return Either an error or list of applied migrations + */ + suspend fun getAppliedMigrations(limit: Int, offset: Int): Either> + + /** + * Find a specific applied migration by version. + * + * @param version The migration version to find + * @return Either an error or the applied migration (null if not found) + */ + suspend fun findByVersion(version: Long): Either + + /** + * Get the current (highest) migration version. + * Returns 0 if no migrations have been applied. + * + * @return Either an error or the current version + */ + suspend fun getCurrentVersion(): Either + + /** + * Check if a specific migration version has been applied. + * + * @param version The migration version to check + * @return Either an error or true if applied, false otherwise + */ + suspend fun isVersionApplied(version: Long): Either + + /** + * Get migration statistics. + * + * @return Either an error or migration statistics + */ + suspend fun getMigrationStatistics(): Either + + /** + * Validate the migration sequence for gaps or inconsistencies. + * + * @return Either an error or validation result + */ + suspend fun validateMigrationSequence(): Either +} + +/** + * Statistics about applied migrations. + */ +data class MigrationStatistics( + val totalMigrations: Long, + val firstVersion: Long?, + val currentVersion: Long?, + val firstApplied: Instant?, + val lastApplied: Instant?, + val totalExecutionTime: Duration, +) + +/** + * Result of migration sequence validation. + */ +data class SequenceValidationReport(val isValid: Boolean, val gaps: List = emptyList(), val inconsistencies: List = emptyList()) + +/** + * SQLDelight implementation of SchemaVersionRepository. + */ +class SqlDelightSchemaVersionStore(private val database: PlatformDatabase) : SchemaVersionStore { + + override suspend fun saveAppliedMigration(migration: AppliedMigration): Either = withContext(Dispatchers.IO) { + try { + database.schemaVersionQueries.insertMigration( + version = migration.version, + description = migration.description, + applied_at = migration.appliedAt.toEpochMilliseconds(), + execution_time_ms = migration.executionTime.inWholeMilliseconds, + ) + Unit.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "save applied migration", + cause = e, + ).left() + } + } + + override suspend fun getAllAppliedMigrations(): Either> = withContext(Dispatchers.IO) { + try { + val migrations = database.schemaVersionQueries.getAllApplied() + .executeAsList() + .map { it.toAppliedMigration() } + migrations.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "get all applied migrations", + cause = e, + ).left() + } + } + + override suspend fun getAppliedMigrations(limit: Int, offset: Int): Either> = withContext(Dispatchers.IO) { + try { + val migrations = database.schemaVersionQueries.getAllAppliedPaged( + limit.toLong(), + offset.toLong(), + ).executeAsList().map { it.toAppliedMigration() } + migrations.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "get applied migrations with pagination", + cause = e, + ).left() + } + } + + override suspend fun findByVersion(version: Long): Either = withContext(Dispatchers.IO) { + try { + val migration = database.schemaVersionQueries.findByVersion(version) + .executeAsOneOrNull() + ?.toAppliedMigration() + migration.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "find migration by version", + cause = e, + ).left() + } + } + + override suspend fun getCurrentVersion(): Either = withContext(Dispatchers.IO) { + try { + val version = database.schemaVersionQueries.getCurrentVersion() + .executeAsOne() + version.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "get current version", + cause = e, + ).left() + } + } + + override suspend fun isVersionApplied(version: Long): Either = withContext(Dispatchers.IO) { + try { + val exists = database.schemaVersionQueries.existsByVersion(version) + .executeAsOne() + exists.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "check if version is applied", + cause = e, + ).left() + } + } + + override suspend fun getMigrationStatistics(): Either = withContext(Dispatchers.IO) { + try { + val stats = database.schemaVersionQueries.getMigrationStats().executeAsOne() + val statistics = MigrationStatistics( + totalMigrations = stats.count ?: 0L, + firstVersion = stats.min_version, + currentVersion = stats.max_version, + firstApplied = stats.min_applied_at?.let { Instant.fromEpochMilliseconds(it) }, + lastApplied = stats.max_applied_at?.let { Instant.fromEpochMilliseconds(it) }, + totalExecutionTime = (stats.total_execution_time ?: 0L).milliseconds, + ) + statistics.right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "get migration statistics", + cause = e, + ).left() + } + } + + override suspend fun validateMigrationSequence(): Either = withContext(Dispatchers.IO) { + try { + val sequences = database.schemaVersionQueries.validateMigrationSequence() + .executeAsList() + + val gaps = mutableListOf() + val inconsistencies = mutableListOf() + + for (sequence in sequences) { + val version = sequence.version + val gap = sequence.gap + if ((gap ?: 0L) > 1) { + gaps.add(version ?: 0L) + inconsistencies.add( + "Gap detected: missing versions between ${(version ?: 0L) - (gap ?: 0L)} and ${version ?: 0L}", + ) + } + } + + SequenceValidationReport( + isValid = gaps.isEmpty(), + gaps = gaps, + inconsistencies = inconsistencies, + ).right() + } catch (e: Exception) { + MigrationError.DatabaseError( + operation = "validate migration sequence", + cause = e, + ).left() + } + } + + private fun Schema_versions.toAppliedMigration(): AppliedMigration = AppliedMigration( + version = version, + description = description, + appliedAt = Instant.fromEpochMilliseconds(applied_at), + executionTime = execution_time_ms.milliseconds, + ) +} diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/discovery/ResourceMigrationDiscovery.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/discovery/ResourceMigrationDiscovery.kt new file mode 100644 index 000000000..cd8bba713 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/discovery/ResourceMigrationDiscovery.kt @@ -0,0 +1,151 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration.scanner + +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.Migration +import io.github.kamiazya.scopes.platform.infrastructure.database.migration.SqlMigration +import io.github.kamiazya.scopes.platform.observability.logging.Logger +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.JarURLConnection +import java.net.URL +import java.util.jar.JarFile + +/** + * Discovers migration files from the classpath resources. + * + * This implementation looks for SQL migration files in the specified resource path + * and creates Migration instances from them. Migration files should follow the + * naming convention: V{version}__{description}.sql + * + * For example: + * - V1__Initial_schema.sql + * - V2__Add_user_preferences.sql + * - V3__Create_indices.sql + */ +class ResourceMigrationDiscovery( + private val resourcePath: String, + private val classLoader: ClassLoader = Thread.currentThread().contextClassLoader, + private val logger: Logger, +) { + + fun discoverMigrations(): List { + logger.debug("Discovering migrations from resources at: $resourcePath") + + val migrations = mutableListOf() + + try { + // Get all resources in the migration path + val resources = findResources() + + resources.forEach { resourceUrl -> + val resourceName = resourceUrl.toString().substringAfterLast('/') + + if (resourceName.endsWith(".sql") && isValidMigrationFile(resourceName)) { + logger.debug("Found migration file: $resourceName") + + try { + val migration = createMigrationFromResource(resourceUrl, resourceName) + migrations.add(migration) + logger.debug("Loaded migration: ${migration.version} - ${migration.description}") + } catch (e: Exception) { + logger.error("Failed to load migration from $resourceName", throwable = e) + } + } + } + + // Sort migrations by version + migrations.sortBy { it.version } + + logger.info("Discovered ${migrations.size} migrations from resources") + } catch (e: Exception) { + logger.error("Failed to discover migrations from resources", throwable = e) + } + + return migrations + } + + private fun findResources(): List { + val resources = mutableListOf() + + // Try to get resources from the classloader + val urls = classLoader.getResources(resourcePath) + + while (urls.hasMoreElements()) { + val url = urls.nextElement() + + when (url.protocol) { + "file" -> { + // Handle file system resources + val file = java.io.File(url.toURI()) + if (file.isDirectory) { + file.listFiles()?.forEach { migrationFile -> + if (migrationFile.isFile && migrationFile.name.endsWith(".sql")) { + resources.add(migrationFile.toURI().toURL()) + } + } + } + } + + "jar" -> { + // Handle resources inside JAR files via JarURLConnection for robustness + val connection = url.openConnection() + val jarFile: JarFile + val prefix: String + if (connection is JarURLConnection) { + jarFile = connection.jarFile + prefix = (connection.entryName?.let { "$it/" } ?: "$resourcePath/") + } else { + // Fallback: parse path + val jarPath = url.path.substringBefore("!") + jarFile = JarFile(jarPath.removePrefix("file:")) + prefix = "$resourcePath/" + } + + jarFile.use { jf -> + jf.entries().asSequence() + .filter { entry -> + entry.name.startsWith(prefix) && + entry.name.endsWith(".sql") && + !entry.isDirectory + } + .forEach { entry -> + classLoader.getResource(entry.name)?.let { resources.add(it) } + } + } + } + } + } + + return resources + } + + private fun isValidMigrationFile(filename: String): Boolean { + // Check if filename matches the pattern V{version}__{description}.sql + val regex = Regex("V(\\d+)__(.+)\\.sql") + return regex.matches(filename) + } + + private fun createMigrationFromResource(resourceUrl: URL, filename: String): Migration { + // Parse version and description from filename + val regex = Regex("V(\\d+)__(.+)\\.sql") + val matchResult = regex.matchEntire(filename) + require(matchResult != null) { "Invalid migration filename: $filename" } + + val version = matchResult!!.groupValues[1].toLong() + val description = matchResult.groupValues[2].replace('_', ' ') + + // Read SQL content + val sql = resourceUrl.openStream().use { stream -> + BufferedReader(InputStreamReader(stream)).use { reader -> + reader.readText() + } + } + + // Create an anonymous SqlMigration with the loaded content + return object : SqlMigration( + version = version, + description = description, + ) { + override val sql: List = sql.split(";").filter { it.isNotBlank() }.map { it.trim() } + } + } +} diff --git a/platform/infrastructure/src/main/sqldelight/io/github/kamiazya/scopes/platform/db/SchemaVersion.sq b/platform/infrastructure/src/main/sqldelight/io/github/kamiazya/scopes/platform/db/SchemaVersion.sq new file mode 100644 index 000000000..b858226bd --- /dev/null +++ b/platform/infrastructure/src/main/sqldelight/io/github/kamiazya/scopes/platform/db/SchemaVersion.sq @@ -0,0 +1,71 @@ +-- Schema Versions table for tracking applied database migrations +CREATE TABLE IF NOT EXISTS schema_versions ( + version INTEGER PRIMARY KEY NOT NULL, + description TEXT NOT NULL, + applied_at INTEGER NOT NULL, + execution_time_ms INTEGER NOT NULL +); + +-- Index for efficient version ordering queries +CREATE INDEX IF NOT EXISTS idx_schema_versions_applied_at ON schema_versions(applied_at); + +-- Insert a new applied migration record +insertMigration: +INSERT INTO schema_versions (version, description, applied_at, execution_time_ms) +VALUES (?, ?, ?, ?); + +-- Find migration by version +findByVersion: +SELECT * +FROM schema_versions +WHERE version = ?; + +-- Get all applied migrations ordered by version +getAllApplied: +SELECT * +FROM schema_versions +ORDER BY version ASC; + +-- Get all applied migrations ordered by version with limit +getAllAppliedPaged: +SELECT * +FROM schema_versions +ORDER BY version ASC +LIMIT ? OFFSET :value_; + +-- Get the current (highest) migration version +getCurrentVersion: +SELECT COALESCE(MAX(version), 0) AS version +FROM schema_versions; + + +-- Check if a specific migration version exists +existsByVersion: +SELECT COUNT(*) > 0 AS result +FROM schema_versions +WHERE version = ?; + + +-- Count total applied migrations +countApplied: +SELECT COUNT(*) +FROM schema_versions; + +-- Get migration statistics +getMigrationStats: +SELECT + COUNT(*) AS count, + MIN(version) AS min_version, + MAX(version) AS max_version, + MIN(applied_at) AS min_applied_at, + MAX(applied_at) AS max_applied_at, + SUM(execution_time_ms) AS total_execution_time +FROM schema_versions; + +-- Validate migration integrity by checking for gaps +validateMigrationSequence: +SELECT + version, + version - LAG(version, 1, 0) OVER (ORDER BY version) AS gap +FROM schema_versions +ORDER BY version; \ No newline at end of file diff --git a/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutorTest.kt b/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutorTest.kt new file mode 100644 index 000000000..a0c766ebe --- /dev/null +++ b/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationExecutorTest.kt @@ -0,0 +1,134 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.test.runTest +import java.io.File + +class MigrationExecutorTest : + DescribeSpec({ + + describe("SqlDelightMigrationExecutor") { + + fun createTestExecutor(): Pair { + val tempFile = File.createTempFile("test_executor_", ".db") + tempFile.deleteOnExit() + + val driver = JdbcSqliteDriver("jdbc:sqlite:${tempFile.absolutePath}") + val executor = SqlDelightMigrationExecutor(driver) + + return driver to executor + } + + afterSpec { + // Clean up any remaining resources + } + + it("should execute single SQL statement") { + runTest { + val (driver, executor) = createTestExecutor() + + try { + val sql = """ + CREATE TABLE test_table ( + id INTEGER PRIMARY KEY, + name TEXT + ) + """.trimIndent() + + val result = executor.executeSql(sql) + result.shouldBeRight() + + // Verify table exists + val tableExists = executor.tableExists("test_table").shouldBeRight() + tableExists shouldBe true + } finally { + driver.close() + } + } + } + + it("should execute multiple SQL statements") { + runTest { + val (driver, executor) = createTestExecutor() + + try { + val statements = listOf( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", + "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, content TEXT)", + "CREATE INDEX idx_posts_user ON posts(user_id)", + ) + + val result = executor.executeSql(statements) + result.shouldBeRight() + + // Verify tables exist + val usersExists = executor.tableExists("users").shouldBeRight() + usersExists shouldBe true + + val postsExists = executor.tableExists("posts").shouldBeRight() + postsExists shouldBe true + } finally { + driver.close() + } + } + } + + it("should handle error in SQL statements atomically") { + runTest { + val (driver, executor) = createTestExecutor() + + try { + val statements = listOf( + "CREATE TABLE test_error (id INTEGER PRIMARY KEY)", + "INVALID SQL STATEMENT", // This will cause an error + ) + + val result = executor.executeSql(statements) + val error = result.shouldBeLeft().shouldBeInstanceOf() + error.sql shouldBe "INVALID SQL STATEMENT" + + // With atomic execution, no statements should be committed + val tableExists = executor.tableExists("test_error").shouldBeRight() + tableExists shouldBe false + } finally { + driver.close() + } + } + } + + it("should create schema_versions table") { + runTest { + val (driver, executor) = createTestExecutor() + + try { + val result = executor.ensureSchemaVersionsTable() + result.shouldBeRight() + + // Verify table exists + val tableExists = executor.tableExists("schema_versions").shouldBeRight() + tableExists shouldBe true + } finally { + driver.close() + } + } + } + + it("should handle empty statement lists") { + runTest { + val (driver, executor) = createTestExecutor() + + try { + val result = executor.executeSql(emptyList()) + result.shouldBeRight() + } finally { + driver.close() + } + } + } + } + }) diff --git a/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManagerIntegrationTest.kt b/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManagerIntegrationTest.kt new file mode 100644 index 000000000..ad1f57057 --- /dev/null +++ b/platform/infrastructure/src/test/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationManagerIntegrationTest.kt @@ -0,0 +1,159 @@ +package io.github.kamiazya.scopes.platform.infrastructure.database.migration + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.github.kamiazya.scopes.platform.db.PlatformDatabase +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.test.runTest +import java.io.File + +class MigrationManagerIntegrationTest : + DescribeSpec({ + + describe("MigrationManager Integration") { + + fun createTestSetup(): Triple { + // Create temporary database + val tempFile = File.createTempFile("test_migration_", ".db") + tempFile.deleteOnExit() + + val driver = JdbcSqliteDriver("jdbc:sqlite:${tempFile.absolutePath}") + + // Create the database schema + PlatformDatabase.Schema.create(driver) + val database = PlatformDatabase(driver) + + val executor = SqlDelightMigrationExecutor(driver) + val repository = SqlDelightSchemaVersionStore(database) + + // Define test migrations + val testMigrations = listOf( + object : SqlMigration( + version = 1, + description = "Create users table", + ) { + override val sql = listOf( + """ + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL + ) + """.trimIndent(), + ) + }, + object : SqlMigration( + version = 2, + description = "Add email to users", + ) { + override val sql = listOf( + "ALTER TABLE users ADD COLUMN email TEXT", + ) + }, + ) + + val migrationManager = DefaultMigrationManager( + executor = executor, + repository = repository, + migrationProvider = { testMigrations }, + ) + + return Triple(driver, database, migrationManager) + } + + it("should report initial status with no migrations applied") { + runTest { + val (driver, _, migrationManager) = createTestSetup() + + try { + val result = migrationManager.getStatus() + + result.shouldBeInstanceOf>() + val status = result.value + + status.currentVersion shouldBe 0L + status.availableMigrations shouldHaveSize 2 + status.appliedMigrations.shouldBeEmpty() + status.pendingMigrations shouldHaveSize 2 + status.isUpToDate shouldBe false + status.hasGaps shouldBe false + } finally { + driver.close() + } + } + } + + it("should apply all migrations successfully") { + runTest { + val (driver, _, migrationManager) = createTestSetup() + + try { + val result = migrationManager.migrateUp() + + result.shouldBeInstanceOf>() + val migrationResult = result.value + + migrationResult.executedMigrations shouldHaveSize 2 + migrationResult.fromVersion shouldBe 0L + migrationResult.toVersion shouldBe 2L + + // Verify status after migration + val status = migrationManager.getStatus() + status.shouldBeInstanceOf>() + status.value.currentVersion shouldBe 2L + status.value.isUpToDate shouldBe true + } finally { + driver.close() + } + } + } + + it("should migrate to specific version") { + runTest { + val (driver, _, migrationManager) = createTestSetup() + + try { + val result = migrationManager.migrateTo(1L) + + result.shouldBeInstanceOf>() + val migrationResult = result.value + + migrationResult.executedMigrations shouldHaveSize 1 + migrationResult.toVersion shouldBe 1L + + // Verify only first migration was applied + val status = migrationManager.getStatus() + status.shouldBeInstanceOf>() + status.value.currentVersion shouldBe 1L + status.value.pendingMigrations shouldHaveSize 1 + } finally { + driver.close() + } + } + } + + it("should validate migration sequence") { + runTest { + val (driver, _, migrationManager) = createTestSetup() + + try { + // Apply migrations + migrationManager.migrateUp() + + val result = migrationManager.validate() + + result.shouldBeInstanceOf>() + val validation = result.value + + validation.isValid shouldBe true + validation.gaps.shouldBeEmpty() + validation.inconsistencies.shouldBeEmpty() + } finally { + driver.close() + } + } + } + } + }) From 9346f179daba68f65c213a3b065ca671fb5239a4 Mon Sep 17 00:00:00 2001 From: Yuki Yamazaki Date: Fri, 26 Sep 2025 23:48:36 +0900 Subject: [PATCH 2/2] fix: address code quality issues from AI review Based on CodeRabbit and Sonar analysis: 1. Fixed unused import by using kotlinx.datetime.Instant 2. Optimized SQL indexes - removed redundant indexes on columns with UNIQUE constraints 3. Removed AUTOINCREMENT from scope_aspects table (SQLite best practice) 4. Removed redundant PRAGMA settings already set in ManagedSqlDriver These changes improve performance by reducing unnecessary database overhead while maintaining all functionality. All tests pass (32/32 in platform-infrastructure). --- .../V1__Initial_scope_management_schema.sql | 8 ++--- package.json | 34 +++++++++---------- .../database/migration/Migration.kt | 2 +- .../MigrationAwareDatabaseProvider.kt | 13 ------- 4 files changed, 22 insertions(+), 35 deletions(-) diff --git a/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql b/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql index 49aa9275d..5e6c91c90 100644 --- a/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql +++ b/contexts/scope-management/infrastructure/src/main/resources/migrations/scope-management/V1__Initial_scope_management_schema.sql @@ -33,12 +33,12 @@ CREATE TABLE IF NOT EXISTS scope_aliases ( -- Scope aliases indexes CREATE INDEX IF NOT EXISTS idx_scope_aliases_scope_id ON scope_aliases(scope_id); -CREATE INDEX IF NOT EXISTS idx_scope_aliases_alias_name ON scope_aliases(alias_name); +-- idx_scope_aliases_alias_name is redundant - alias_name already has UNIQUE constraint CREATE INDEX IF NOT EXISTS idx_scope_aliases_alias_type ON scope_aliases(alias_type); -- Scope aspects table (align with SQLDelight schema) CREATE TABLE IF NOT EXISTS scope_aspects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id INTEGER PRIMARY KEY, scope_id TEXT NOT NULL, aspect_key TEXT NOT NULL, aspect_value TEXT NOT NULL, @@ -63,8 +63,8 @@ CREATE TABLE IF NOT EXISTS context_views ( ); -- Context views indexes -CREATE INDEX IF NOT EXISTS idx_context_views_key ON context_views(key); -CREATE INDEX IF NOT EXISTS idx_context_views_name ON context_views(name); +-- idx_context_views_key is redundant - key already has UNIQUE constraint +-- idx_context_views_name is redundant - name already has UNIQUE constraint CREATE INDEX IF NOT EXISTS idx_context_views_created_at ON context_views(created_at DESC); CREATE INDEX IF NOT EXISTS idx_context_views_updated_at ON context_views(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_context_views_key_filter ON context_views(key, filter) WHERE filter IS NOT NULL; diff --git a/package.json b/package.json index d09e44dda..f61b57c7a 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,22 @@ { - "name": "scopes", - "version": "0.0.2", - "private": true, - "scripts": { - "changeset": "changeset", - "changeset:add": "changeset add", - "changeset:status": "changeset status", - "version-packages": "changeset version", - "tag": "changeset tag" + "name" : "scopes", + "version" : "0.0.2", + "private" : true, + "scripts" : { + "changeset" : "changeset", + "changeset:add" : "changeset add", + "changeset:status" : "changeset status", + "version-packages" : "changeset version", + "tag" : "changeset tag" }, - "author": "Yuki Yamazaki ", - "license": "Apache-2.0", - "engines": { - "node": ">=24" + "author" : "Yuki Yamazaki ", + "license" : "Apache-2.0", + "engines" : { + "node" : ">=24" }, - "packageManager": "pnpm@10.6.5", - "devDependencies": { - "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.29.7" + "packageManager" : "pnpm@10.6.5", + "devDependencies" : { + "@changesets/changelog-github" : "^0.5.1", + "@changesets/cli" : "^2.29.7" } } diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt index ca59e04bc..2fe54390e 100644 --- a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/Migration.kt @@ -1,8 +1,8 @@ package io.github.kamiazya.scopes.platform.infrastructure.database.migration import arrow.core.Either -import io.github.kamiazya.scopes.platform.commons.time.Instant import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds diff --git a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt index d63925669..329b4f743 100644 --- a/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt @@ -41,14 +41,6 @@ class MigrationAwareDatabaseProvider( val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath) val driver = managedDriver.driver - // Explicitly apply PRAGMA settings to ensure they are set - // This is redundant with connection properties but ensures settings are applied - driver.execute(null, "PRAGMA foreign_keys = ON", 0) - driver.execute(null, "PRAGMA journal_mode = WAL", 0) - driver.execute(null, "PRAGMA synchronous = NORMAL", 0) - driver.execute(null, "PRAGMA cache_size = -64000", 0) - driver.execute(null, "PRAGMA temp_store = MEMORY", 0) - // Initialize migration components val platformDatabase = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) val executor = SqlDelightMigrationExecutor(driver) @@ -99,11 +91,6 @@ class MigrationAwareDatabaseProvider( val managedDriver = ManagedSqlDriver(":memory:") val driver = managedDriver.driver - // Apply PRAGMA settings for in-memory database - driver.execute(null, "PRAGMA foreign_keys = ON", 0) - driver.execute(null, "PRAGMA synchronous = OFF", 0) // OFF is safe for in-memory - driver.execute(null, "PRAGMA temp_store = MEMORY", 0) - // Initialize migration components val platformDatabase = io.github.kamiazya.scopes.platform.db.PlatformDatabase(driver) val executor = SqlDelightMigrationExecutor(driver)