apply-upstream-migration
SkillDatabases & dataApply a new upstream Actual migration to Aktual — update MigrateDatabase.kt, SQLDelight .sq files, Adapters, model types, and the migration test. Triggered when last-known-migration.txt is updated.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the apply-upstream-migration skill
What this skill tells your AI
The instructions your AI receives, as published by jonapoul/aktual in .claude/skills/apply-upstream-migration/SKILL.md and read by ahel’s review.
Apply a new upstream Actual database migration to Aktual's database layer.
Do NOT ask clarifying questions — just execute.
Arguments
$ARGUMENTSis the migration filename, e.g.1778510362740_add_cleanup_groups_and_def.sql- If omitted, read it from
.github/upstream-migration-tracker/last-known-migration.txt
Step-by-Step Process
1. Read the upstream migration SQL
The migration lives at:
/home/jon/dev/actual/packages/loot-core/migrations/<filename>
Parse all DDL statements: ALTER TABLE ... ADD COLUMN, CREATE TABLE, CREATE INDEX, etc. Strip BEGIN TRANSACTION / COMMIT wrappers.
2. Categorise the changes
For each statement:
| Statement | Actions needed |
|---|---|
ALTER TABLE t ADD COLUMN c | Update .sq file + possible adapter |
CREATE TABLE t | New .sq file + adapter + BuildDatabase.kt + possible ID type. Emit as CREATE TABLE IF NOT EXISTS in the migration (never bare CREATE TABLE) |
CREATE INDEX | Add to the relevant .sq file (no adapter) |
Also check if new column types need new model classes (sealed interfaces, value class IDs).
3. Update SQLDelight schema files
All .sq files: aktual-budget/data/db/src/commonMain/sqldelight/aktual/budget/db/
New column on an existing table:
- Add the column to the
CREATE TABLEstatement in the matching.sqfile - If it stores typed JSON, declare it as
TEXT AS MyType DEFAULT NULLwith an import at the top - If it stores a typed ID, declare it as
TEXT AS FooId DEFAULT NULL
New table:
- Create
Foo.sq(PascalCase, matching the table name) - Mirror upstream columns with SQLDelight typed aliases
- Use
tombstone INTEGER AS Boolean DEFAULT 0for the tombstone column - Add at minimum:
insert: INSERT OR REPLACE INTO foo(id, ...) VALUES (?, ...);andgetById: SELECT * FROM foo WHERE id = ?;
4. Add model types if needed
Types live in aktual-budget/model/src/commonMain/kotlin/aktual/budget/model/.
New table ID — add to Ids.kt, following the existing value class pattern:
@JvmInline
@Serializable
value class FooId(val value: String) : Comparable<FooId> {
override fun toString(): String = value
override fun compareTo(other: FooId) = value.compareTo(other.value)
}
New JSON column type — check the upstream TypeScript type at actual/packages/loot-core/src/types/models/ in the parent repo, then create a new file:
- Discriminated union →
sealed interface+JsonContentPolymorphicSerializer(seeCleanupTemplate.ktas a reference) - Plain object →
@Serializable data class - Use typed IDs (e.g.
FooId) for any ID fields, not rawString
5. Update Adapters.kt
File: aktual-budget/data/db/src/commonMain/kotlin/aktual/budget/db/Adapters.kt
- New JSON column (single):
private val foo = jsonSerializable(Foo.serializer()) - New JSON column (array):
private val foos = jsonSerializable(ListSerializer(Foo.serializer())) - New ID column:
private val fooId = stringAdapter(::FooId) - Wire the adapter into the relevant
internal val FooAdapter = Foo.Adapter(...)block, or create a newinternal valif a new table was added
6. Update BuildDatabase.kt
File: aktual-budget/data/db/src/commonMain/kotlin/aktual/budget/db/BuildDatabase.kt
Pass any new table adapter into BudgetDatabase(...). The parameter name is the snake_case table name with Adapter appended directly (no extra underscore):
fooAdapter = FooAdapter, // single-word table: foo
foo_barsAdapter = FooBarsAdapter, // multi-word table: foo_bars
7. Add the migration function in MigrateDatabase.kt
File: aktual-budget/data/db/src/commonMain/kotlin/aktual/budget/db/MigrateDatabase.kt
Add the call inside with(migrator) { ... } in migrateDatabase:
migrate1778510362740()
(Name = migrate + the numeric version from the filename.)
Add the function at the bottom of the file:
// packages/loot-core/migrations/1778510362740_add_cleanup_groups_and_def.sql
private suspend fun Migrator.migrate1778510362740() =
migrateTo(
version = 1778510362740L,
"ALTER TABLE categories ADD COLUMN cleanup_def TEXT DEFAULT NULL",
"CREATE TABLE IF NOT EXISTS cleanup_groups(id TEXT PRIMARY KEY, name TEXT NOT NULL, tombstone INTEGER DEFAULT 0)",
)
Rules:
- One String per DDL statement —
driver.execute()doesn't support semicolon-separated multi-statement strings CREATE TABLE IF NOT EXISTS— always emit this form, even when upstream wrote a bareCREATE TABLE; fresh databases already have the table from the schemaALTER TABLE ... ADD COLUMN— use as-is; theversion !in previousMigrationsguard handles idempotency for existing databases- Never call
BudgetDatabase.Schema.migrate()— upstream doesn't useuser_version
8. Update the migration test
File: aktual-budget/data/db/src/commonTest/kotlin/aktual/budget/db/DatabaseMigrationTest.kt
Add a call at the end of the test body:
checkMigration1778510362740(db)
(Name = checkMigration + the numeric version.)
Add the check function. The query must reference the new column or table so it fails to compile if the schema change wasn't applied.
New table — query by ID, expect null:
// Adds cleanup_groups table
private suspend fun checkMigration1778510362740(db: BudgetDatabase) {
val result = db.cleanupGroupsQueries.getById(CleanupGroupId("nonexistent")).awaitAsOneOrNull()
assertNull(result)
}
New column on an existing table — use (or add) a query that selects that column:
// Adds custom_upcoming_length column to schedules
private suspend fun checkMigration1769000000000(db: BudgetDatabase) {
val result = db.schedulesQueries.getCustomUpcomingLength(ScheduleId("nonexistent")).awaitAsOneOrNull()
assertNull(result)
}
Add a named query to the .sq file if one doesn't already return the new column.
9. Compile and test
./gradlew :aktual-budget:data:db:compileAll
./gradlew :aktual-budget:data:db:testAndroidHostTest
Fix any adapter or schema errors before declaring done.
Important Notes
- Always check the upstream TypeScript types when adding a JSON column — the Kotlin model must match the serialised shape exactly
schemas/1.dbis the SQLDelight schema snapshot used for fresh database creation — it is regenerated automatically when SQLDelight compiles the.sqfiles- The
__migrations__table tracks which Aktual migrations have run; upstream Actual's own migration tracking is completely separate - The migration SQL runs against existing Actual databases opened from file — fresh databases already contain all tables/columns from the schema, hence
CREATE TABLE IF NOT EXISTS
Signals
- GitHub stars
- 34
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
apply-upstream-migration- Source
- github.com/jonapoul/aktual