準備加新功能時,我發現一個問題。
SQLDelight 的文件提到 migration,但語焉不詳。我的專案要怎麼處理資料庫版本更新?
看了看現有程式碼:
// 直接 create table,沒有版本控制
GrimoDatabase.Schema.create(driver)
這樣不行啊。用戶更新應用程式時,資料庫結構變了怎麼辦?
「請以 ARS 角色,研究 SQLDelight 2.0 最新的 migration 最佳實踐。」
ARS 的研究報告:
## 研究報告:SQLDelight Migration 方案
### 執行摘要
- SQLDelight 2.0 新增 deriveSchemaFromMigrations
- 使用 PRAGMA user_version 管理版本
- .sqm 檔案專門處理 migration
### 關鍵發現
1. **deriveSchemaFromMigrations** - Schema 從 .sqm 產生
2. **PRAGMA user_version** - SQLite 內建版本控制
3. **自動 transaction** - migration 自動包裝
### 業界實踐
- Cash App:100+ migrations,只用 30 行 helper
- Signal Desktop:用 PRAGMA user_version
「什麼是 .sqm?跟 .sq 有什麼差別?」我問。
「Claude Code,根據 ARS 的研究,幫我理解 deriveSchemaFromMigrations 怎麼運作。」
Claude Code 解釋:「這是 SQLDelight 2.0 的新功能。設定後,資料庫 schema 完全從 migration 檔案產生,而不是從 .sq 檔案。」
「那版本控制呢?」我問。
「SQLite 有內建的 PRAGMA user_version,」Claude Code 說,「不需要額外的版本表。」
PRAGMA user_version?我從來沒用過。
「怎麼用 PRAGMA user_version?」我自己試試看:
-- 查詢目前版本
PRAGMA user_version;
-- 結果:0
-- 設定版本
PRAGMA user_version = 1;
-- 再次查詢
PRAGMA user_version;
-- 結果:1
原來這麼簡單!SQLite 內建的功能。
參考文件和測試後,我寫了簡單的 MigrationHelper:
object MigrationHelper {
    suspend fun migrateIfNeeded(driver: SqlDriver) {
        val currentVersion = getCurrentVersion(driver)
        val targetVersion = GrimoDatabase.Schema.version
        
        when {
            currentVersion == 0L -> {
                // 新資料庫,直接建立
                GrimoDatabase.Schema.create(driver).await()
            }
            currentVersion < targetVersion -> {
                // 需要升級
                GrimoDatabase.Schema.migrate(
                    driver, currentVersion, targetVersion
                ).await()
            }
        }
        
        setVersion(driver, targetVersion)
    }
    
    private fun getCurrentVersion(driver: SqlDriver): Long =
        driver.executeQuery(
            null, "PRAGMA user_version", 
            { cursor -> QueryResult.Value(cursor.getLong(0) ?: 0L) }, 
            0
        ).value
        
    private fun setVersion(driver: SqlDriver, version: Long) {
        driver.execute(null, "PRAGMA user_version = $version", 0)
    }
}
30 行左右,簡潔明瞭。
看了文件後,我修改 build.gradle.kts:
sqldelight {
    databases {
        create("GrimoDatabase") {
            packageName.set("io.github.grimostudio.grimo.db")
            
            // 從 migration 檔案產生 schema
            deriveSchemaFromMigrations.set(true)
            
            // 其他設定
            generateAsync.set(true)        // 支援協程
            verifyMigrations.set(true)     // 編譯時驗證
        }
    }
}
「deriveSchemaFromMigrations 是關鍵,」我理解了,「設定後,不用在 .sq 檔案寫 CREATE TABLE。」
專案結構變成:
sqldelight/
├── migrations/
│   ├── 1.sqm    # 初始 schema
│   └── 2.sqm    # 新增功能
└── io/.../grimo/
    └── Project.sq   # 只有查詢
第一個 migration (1.sqm):
-- 初始資料庫結構
CREATE TABLE Project (
    id TEXT NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    createdAt INTEGER NOT NULL,
    updatedAt INTEGER NOT NULL
);
第二個 migration (2.sqm) 加入 EventStore:
-- 支援 CloudEvents 和 Event Sourcing
CREATE TABLE IF NOT EXISTS event_store (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    stream_id TEXT NOT NULL,
    cloud_event_id TEXT UNIQUE NOT NULL,
    -- ... 更多欄位
);
清空資料庫,重新執行:
資料庫版本: 0
執行 Schema.create()
設定版本為 2
成功!
把 user_version 改成 1,再執行:
資料庫版本: 1
執行 Schema.migrate(1, 2)
設定版本為 2
也成功了!
一開始我把 migration 檔案命名成 001.sqm、002.sqm。
結果編譯錯誤:
Expected migration file to be named <version>.sqm
「噢,要用數字當檔名,」我改成 1.sqm、2.sqm,問題解決。
「Claude Code,migration 檔案是依序執行嗎?」
「對,」Claude Code 回答,「從當前版本執行到目標版本。比如從版本 1 升級到版本 3,會依序執行 2.sqm 和 3.sqm。」
「那如果中間有錯誤呢?」
「SQLDelight 會自動包在 transaction 裡,失敗就回滾。」
原來如此,不用自己處理 transaction。
「有幾個最佳實踐,」Claude Code 建議:
我記下這些原則。
我的專案第二個 migration 比較複雜,加入 EventStore 支援 CloudEvents:
-- Migration 2: EventStore for CloudEvents and Event Sourcing
CREATE TABLE IF NOT EXISTS event_store (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    stream_id TEXT NOT NULL,
    stream_version INTEGER NOT NULL,
    cloud_event_id TEXT UNIQUE NOT NULL,
    cloud_event_source TEXT NOT NULL,
    cloud_event_type TEXT NOT NULL,
    -- ... 更多欄位
    
    UNIQUE(stream_id, stream_version),
    CHECK(stream_version > 0)
);
-- 建立索引提升查詢效能
CREATE INDEX IF NOT EXISTS idx_event_store_stream 
ON event_store(stream_id, stream_version);
這個 migration 有 87 行,但很清楚:建立事件存儲系統。
原本想寫一個「完美」的 migration 系統,支援:
結果發現 SQLDelight + PRAGMA user_version 就夠了。
剛開始不懂 deriveSchemaFromMigrations,查了文件、看了範例、問了 Claude Code,慢慢理解。
現在回頭看,其實概念很簡單:
SQLDelight 團隊已經想過這些問題,提供了優雅的解決方案。
我不需要重新發明輪子,專注在業務邏輯就好。
今天研究了 SQLDelight 的 migration 機制,從一開始的困惑到最後理解。
關鍵發現:
Claude Code 在這個過程中扮演研究助手的角色,幫我理解概念、分析文件、提供最佳實踐建議。
最大的收穫是學會相信框架。SQLDelight 提供的功能已經很完善,不需要過度設計。
明天繼續探索其他架構優化的可能性。
「最好的架構是看不見的架構。」
關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理平台。
專案連結:GitHub - grimostudio