Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/database-migration-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"scopes": minor
---

Add automatic database migration system for SQLDelight databases

- Implement DatabaseMigrationManager with thread-safe migration execution
- Add version tracking using SQLite PRAGMA user_version
- Support for custom migration callbacks at specific versions
- Automatic schema creation for fresh databases
- Fail-fast behavior when database version is newer than application
- Database-level locking to prevent concurrent migrations across processes
- Proper resource management and exception handling
- Migration files (.sqm) for all bounded contexts (scope-management, event-store, device-synchronization)
- Comprehensive documentation and examples in database-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,31 @@ 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.platform.infrastructure.database.DatabaseMigrationManager
import io.github.kamiazya.scopes.platform.infrastructure.version.ApplicationVersion

/**
* Provides SQLDelight database instances for Device Synchronization.
*
* This provider creates databases with migration support.
*/
object SqlDelightDatabaseProvider {

private val migrationManager = DatabaseMigrationManager.createDefault()

/**
* Creates a new DeviceSyncDatabase instance.
* Automatically handles schema creation and migration based on version differences.
*/
fun createDatabase(databasePath: String): DeviceSyncDatabase {
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:$databasePath")

// Create the database schema
DeviceSyncDatabase.Schema.create(driver)
// Perform migration if needed
migrationManager.migrate(
driver = driver,
schema = DeviceSyncDatabase.Schema,
targetVersion = ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION
)

// Enable foreign keys
driver.execute(null, "PRAGMA foreign_keys=ON", 0)
Expand All @@ -26,10 +37,21 @@ object SqlDelightDatabaseProvider {

/**
* Creates an in-memory database for testing.
* Always creates a fresh schema without migration.
*/
fun createInMemoryDatabase(): DeviceSyncDatabase {
val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)

// For in-memory databases, always create fresh schema
DeviceSyncDatabase.Schema.create(driver)

// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION}",
parameters = 0
)

return DeviceSyncDatabase(driver)
Comment on lines 46 to 55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enable foreign keys in in-memory database provider.

Line 31 enables foreign keys for on-disk databases, but the in-memory path skips it. That leaves test setups without FK enforcement, letting violations slip through locally while production fails. Please mirror the production behaviour.

         // For in-memory databases, always create fresh schema
         DeviceSyncDatabase.Schema.create(driver)

+        // Mirror production FK behaviour for parity with persisted databases
+        driver.execute(null, "PRAGMA foreign_keys=ON", 0)
+
         // Set the version for consistency
         driver.execute(
             identifier = null,
             sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION}",
             parameters = 0
         )
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DeviceSyncDatabase.Schema.create(driver)
// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION}",
parameters = 0
)
return DeviceSyncDatabase(driver)
// For in-memory databases, always create fresh schema
DeviceSyncDatabase.Schema.create(driver)
// Mirror production FK behaviour for parity with persisted databases
driver.execute(
identifier = null,
sql = "PRAGMA foreign_keys=ON",
parameters = 0
)
// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION}",
parameters = 0
)
return DeviceSyncDatabase(driver)
πŸ€– Prompt for AI Agents
In
contexts/device-synchronization/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/devicesync/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt
around lines 45-54, the in-memory database path creates the schema and sets
user_version but does not enable foreign key enforcement, causing tests to skip
FK checks; add an execution of "PRAGMA foreign_keys = ON" on the driver (using
driver.execute with appropriate identifier/sql/parameters) before creating the
schema so the in-memory branch mirrors the on-disk branch’s FK behaviour.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Migration from version 1 to version 2
-- This is a placeholder migration file for the Device Synchronization context
--
-- Example migrations for device sync (uncomment when needed):
--
-- Add new column for sync status
-- ALTER TABLE devices ADD COLUMN last_error TEXT;
--
-- Add table for sync conflicts
-- CREATE TABLE IF NOT EXISTS sync_conflicts (
-- id TEXT PRIMARY KEY NOT NULL,
-- device_id TEXT NOT NULL,
-- conflict_data TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- resolved_at INTEGER,
-- FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
-- );
--
-- Note: This file is currently a placeholder.
-- When you need to make schema changes in the future:
-- 1. Uncomment and modify the SQL statements above
-- 2. Update ApplicationVersion.SchemaVersions.DEVICE_SYNCHRONIZATION to 2
-- 3. Add a new VersionMapping in ApplicationVersion.versionHistory
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,31 @@ 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.platform.infrastructure.database.DatabaseMigrationManager
import io.github.kamiazya.scopes.platform.infrastructure.version.ApplicationVersion

/**
* Provides SQLDelight database instances for Event Store.
*
* This provider creates databases with migration support.
*/
object SqlDelightDatabaseProvider {

private val migrationManager = DatabaseMigrationManager.createDefault()

/**
* Creates a new EventStoreDatabase instance.
* Automatically handles schema creation and migration based on version differences.
*/
fun createDatabase(databasePath: String): EventStoreDatabase {
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:$databasePath")

// Create the database schema
EventStoreDatabase.Schema.create(driver)
// Perform migration if needed
migrationManager.migrate(
driver = driver,
schema = EventStoreDatabase.Schema,
targetVersion = ApplicationVersion.SchemaVersions.EVENT_STORE
)

// Enable foreign keys
driver.execute(null, "PRAGMA foreign_keys=ON", 0)
Expand All @@ -26,10 +37,21 @@ object SqlDelightDatabaseProvider {

/**
* Creates an in-memory database for testing.
* Always creates a fresh schema without migration.
*/
fun createInMemoryDatabase(): EventStoreDatabase {
val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)

// For in-memory databases, always create fresh schema
EventStoreDatabase.Schema.create(driver)

// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.EVENT_STORE}",
parameters = 0
)

return EventStoreDatabase(driver)
Comment on lines 46 to 55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enable foreign keys for the in-memory event-store database too.

Like the device-sync provider, tests using this in-memory DB won't enforce FK constraints, diverging from production. Enable them to keep parity.

         // For in-memory databases, always create fresh schema
         EventStoreDatabase.Schema.create(driver)

+        // Match production FK enforcement
+        driver.execute(null, "PRAGMA foreign_keys=ON", 0)
+
         // Set the version for consistency
         driver.execute(
             identifier = null,
             sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.EVENT_STORE}",
             parameters = 0
         )
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
EventStoreDatabase.Schema.create(driver)
// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.EVENT_STORE}",
parameters = 0
)
return EventStoreDatabase(driver)
// For in-memory databases, always create fresh schema
EventStoreDatabase.Schema.create(driver)
// Match production FK enforcement
driver.execute(null, "PRAGMA foreign_keys=ON", 0)
// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.EVENT_STORE}",
parameters = 0
)
return EventStoreDatabase(driver)
πŸ€– Prompt for AI Agents
In
contexts/event-store/infrastructure/src/main/kotlin/io/github/kamiazya/scopes/eventstore/infrastructure/sqldelight/SqlDelightDatabaseProvider.kt
around lines 45 to 54, the in-memory DB is created without enabling SQLite
foreign key enforcement; add a driver.execute call to run "PRAGMA foreign_keys =
ON" (identifier = null, parameters = 0) so FK constraints are enforced in tests.
Place this execute immediately after obtaining the driver and before creating
the schema (or at least before returning the EventStoreDatabase) so the
in-memory DB behavior matches production.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Migration from version 1 to version 2
-- This is a placeholder migration file for the Event Store context
--
-- Example migrations for event store (uncomment when needed):
--
-- Add new column for event metadata
-- ALTER TABLE events ADD COLUMN correlation_id TEXT;
--
-- Add index for correlation tracking
-- CREATE INDEX IF NOT EXISTS idx_events_correlation ON events(correlation_id);
--
-- Note: This file is currently a placeholder.
-- When you need to make schema changes in the future:
-- 1. Uncomment and modify the SQL statements above
-- 2. Update ApplicationVersion.SchemaVersions.EVENT_STORE to 2
-- 3. Add a new VersionMapping in ApplicationVersion.versionHistory
Original file line number Diff line number Diff line change
@@ -1,48 +1,74 @@
package io.github.kamiazya.scopes.scopemanagement.infrastructure.sqldelight

import io.github.kamiazya.scopes.platform.infrastructure.database.DatabaseMigrationManager
import io.github.kamiazya.scopes.platform.infrastructure.database.ManagedSqlDriver
import io.github.kamiazya.scopes.platform.infrastructure.version.ApplicationVersion
import io.github.kamiazya.scopes.scopemanagement.db.ScopeManagementDatabase

/**
* Provides SQLDelight database instances for Scope Management.
*
* This provider creates databases with automatic resource management.
* This provider creates databases with automatic resource management and migration support.
* The returned ManagedDatabase wrapper ensures proper cleanup on close.
*/
object SqlDelightDatabaseProvider {

private val migrationManager = DatabaseMigrationManager.createDefault()

/**
* Wrapper that combines database and its managed driver for proper cleanup.
* Implements AutoCloseable to ensure resources are properly released.
*/
class ManagedDatabase(private val database: ScopeManagementDatabase, private val managedDriver: AutoCloseable) :
ScopeManagementDatabase by database,
AutoCloseable {
class ManagedDatabase(
private val database: ScopeManagementDatabase,
private val managedDriver: AutoCloseable
) : ScopeManagementDatabase by database, AutoCloseable {
override fun close() {
// Close the driver to release file handles and WAL locks
managedDriver.close()
}
}

/**
* Creates a new ScopeManagementDatabase instance with automatic resource management.
* Automatically handles schema creation and migration based on version differences.
*
* @return ManagedDatabase that must be closed when no longer needed
*/
fun createDatabase(databasePath: String): ScopeManagementDatabase {
fun createDatabase(databasePath: String): ManagedDatabase {
val managedDriver = ManagedSqlDriver.createWithDefaults(databasePath)
val driver = managedDriver.driver

// Create the database schema
ScopeManagementDatabase.Schema.create(driver)
// Perform migration if needed
migrationManager.migrate(
driver = driver,
schema = ScopeManagementDatabase.Schema,
targetVersion = ApplicationVersion.SchemaVersions.SCOPE_MANAGEMENT
)

return ManagedDatabase(ScopeManagementDatabase(driver), managedDriver)
}

/**
* Creates an in-memory database for testing.
* Always creates a fresh schema without migration.
*
* @return ManagedDatabase that must be closed when no longer needed
*/
fun createInMemoryDatabase(): ScopeManagementDatabase {
fun createInMemoryDatabase(): ManagedDatabase {
val managedDriver = ManagedSqlDriver(":memory:")
val driver = managedDriver.driver

// For in-memory databases, always create fresh schema
ScopeManagementDatabase.Schema.create(driver)

// Set the version for consistency
driver.execute(
identifier = null,
sql = "PRAGMA user_version = ${ApplicationVersion.SchemaVersions.SCOPE_MANAGEMENT}",
parameters = 0
)

return ManagedDatabase(ScopeManagementDatabase(driver), managedDriver)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Migration from version 1 to version 2
-- This is an example migration file that will be used when upgrading from schema version 1 to 2
--
-- Example migrations (uncomment and modify as needed for actual schema changes):
--
-- Add a new column to the scopes table
-- ALTER TABLE scopes ADD COLUMN status TEXT DEFAULT 'active';
--
-- Create a new index
-- CREATE INDEX IF NOT EXISTS idx_scopes_status ON scopes(status);
--
-- Add a new table
-- CREATE TABLE IF NOT EXISTS scope_tags (
-- scope_id TEXT NOT NULL,
-- tag TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- PRIMARY KEY (scope_id, tag),
-- FOREIGN KEY (scope_id) REFERENCES scopes(id) ON DELETE CASCADE
-- );
--
-- Note: This file is currently a placeholder.
-- When you need to make schema changes in the future:
-- 1. Uncomment and modify the SQL statements above
-- 2. Update ApplicationVersion.SchemaVersions.SCOPE_MANAGEMENT to 2
-- 3. Add a new VersionMapping in ApplicationVersion.versionHistory
Loading
Loading