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..5e6c91c90 --- /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); +-- 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, + 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 +-- 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; + +-- 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/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/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..2fe54390e --- /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 kotlinx.datetime.Clock +import kotlinx.datetime.Instant +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..329b4f743 --- /dev/null +++ b/platform/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/platform/infrastructure/database/migration/MigrationAwareDatabaseProvider.kt @@ -0,0 +1,141 @@ +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 + + // 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 + + // 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() + } + } + } + } + })