| name | room-migration |
| description | Safely change the TasKan Room schema — add/rename/drop a column, add an entity or index — with a version bump, a tested Migration, and a regenerated schema JSON. Use whenever an @Entity in data/local/entity changes, before writing any UI that depends on the new shape. |
Changing the TasKan database schema
The database lives on the user's device and holds the only copy of their tasks. There is no server
to restore from. Every schema change is therefore a migration, and the migration is tested.
The rule that matters most
Never add fallbackToDestructiveMigration(). It makes the crash go away by deleting every task
the user has. If a migration is failing, fix the migration.
1. Change the entity
Edit the @Entity in data/local/entity/. New columns must be nullable or have a Kotlin default —
existing rows have no value for them.
Update toDomain() / toEntity() in the same file, and the domain type in core/model if the new
field is user-visible.
2. Bump the version
In data/local/TasKanDatabase.kt, increment version. Go up by exactly one.
3. Write the migration
Add it next to the database, and register it on the builder in data/di/DataModule.kt:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE tasks ADD COLUMN reminder_at INTEGER")
}
}
Room.databaseBuilder(context, TasKanDatabase::class.java, TasKanDatabase.NAME)
.addMigrations(MIGRATION_1_2)
.build()
SQLite cannot rename or drop a column portably, and it cannot change a column's type at all. For
anything beyond ADD COLUMN or CREATE INDEX, use the create-copy-drop-rename dance:
connection.execSQL("CREATE TABLE tasks_new (...)")
connection.execSQL("INSERT INTO tasks_new (...) SELECT ... FROM tasks")
connection.execSQL("DROP TABLE tasks")
connection.execSQL("ALTER TABLE tasks_new RENAME TO tasks")
Recreate every index and foreign key on the new table — they do not survive the rename. Because
folders and tasks are joined by foreign keys with ON DELETE CASCADE, getting this wrong
detaches subtrees rather than throwing, so re-read the entity's foreignKeys block against what the
migration creates.
4. Regenerate and commit the schema JSON
./gradlew :app:compileDebugKotlin
This writes app/schemas/com.taskan.data.local.TasKanDatabase/<version>.json. Commit it. Diffing
those files against the migration is how a reviewer — human or otherwise — confirms the two agree.
5. Test the migration
Room's MigrationTestHelper (already on the test classpath via room-testing) opens a database at
the old version, runs the migration, and validates the result against the exported schema:
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
TasKanDatabase::class.java,
)
@Test
fun migrate1To2_keepsExistingTasks() {
helper.createDatabase(TEST_DB, 1).apply {
execSQL("INSERT INTO tasks (id, title, ...) VALUES ('t1', 'Buy milk', ...)")
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
db.query("SELECT title FROM tasks WHERE id = 't1'").use {
assertTrue(it.moveToFirst())
assertEquals("Buy milk", it.getString(0))
}
}
Assert on the data, not just that the migration ran. runMigrationsAndValidate checks the shape;
only your query checks that the rows survived.
6. Check the invariants still hold
After the migration, re-run the data-layer tests — the subtree CTE and the cascade deletes are the
first things a table rewrite breaks:
./gradlew :app:testDebugUnitTest