| name | add-db-migration |
| description | Add a new database migration to EncryptedDb. Use when changing storage schema, data format, or serialization in the native library. |
Add Database Migration
Guide for adding a new migration to rust/src/encrypted_db.rs. Migrations run automatically when EncryptedDb::open() is called.
Architecture
Two separate version counters:
LATEST_SCHEMA_VERSION — data migrations (both platforms, always in sync)
IDB_STRUCTURAL_VERSION — IDB object store changes (WASM only, bump separately)
| Platform | Schema tracking | Migration atomicity |
|---|
| Native (SQLCipher) | db_meta.schema_version row | SQL transaction per migration |
| WASM (IndexedDB) | Encrypted WASM_META_KEY in mls_storage | IDB transaction per migration |
Step-by-Step Checklist
1. Bump LATEST_SCHEMA_VERSION
pub(crate) const LATEST_SCHEMA_VERSION: u32 = 2;
2. Add native migration function
fn migrate_native_v{N-1}_to_v{N}(conn: &rusqlite::Connection) -> Result<(), String> {
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Migration v{N-1}->v{N}: failed to begin transaction: {e}"))?;
tx.execute(
&format!("INSERT OR REPLACE INTO db_meta (key, value) VALUES ('{META_SCHEMA_VERSION}', '{N}')"),
[],
)
.map_err(|e| format!("Migration v{N-1}->v{N}: failed to write version: {e}"))?;
tx.commit()
.map_err(|e| format!("Migration v{N-1}->v{N}: commit failed: {e}"))?;
Ok(())
}
3. Wire native migration into run_migrations()
if version < N {
Self::migrate_native_v{N-1}_to_v{N}(&conn)?;
}
4. Add WASM migration function
async fn migrate_wasm_v{N-1}_to_v{N}(&self) -> Result<(), String> {
self.idb_write_schema_version(N).await?;
Ok(())
}
5. Wire WASM migration into run_migrations()
if version < N {
self.migrate_wasm_v{N-1}_to_v{N}().await?;
}
6. If adding a new IDB object store
Also bump the structural version:
const IDB_STRUCTURAL_VERSION: u32 = 2;
And add to idb_ensure_stores():
if old_version < 2.0 {
db.create_object_store("new_store_name", params).unwrap();
}
7. Add tests
In test/storage_test.dart:
test('schema_version returns expected value after migration', () async {
final engine = await createTestEngine();
expect(engine.schemaVersion(), N); // matches LATEST_SCHEMA_VERSION
});
Existing tests implicitly verify migration idempotency (every createTestEngine() runs migrations on a fresh DB).
8. Verify
make build
make codegen
make analyze
make test
When to bump which version
| Change | LATEST_SCHEMA_VERSION | IDB_STRUCTURAL_VERSION |
|---|
| New SQL column/table | Yes | No |
| Changed data serialization | Yes | No |
| Data restructuring (merge/split) | Yes | No |
| New IDB object store | Yes | Yes |
| Remove IDB object store | Yes | Yes |
| Bug fix (no data change) | No | No |
| New Rust API function | No | No |
Safety rules
- Native: Each migration gets its own SQL transaction. Version is written inside the same transaction. Failure = full rollback, version unchanged.
- WASM: Pre-encrypt all values before opening IDB transaction. Version written atomically with data.
- Never skip versions: Migrations run sequentially
if version < N. A DB at v1 upgrading to v3 runs v1->v2, then v2->v3.
- Test with fresh AND existing DBs: Fresh DB (version 0 -> latest) is tested by every test. For existing DB upgrades, consider adding a dedicated test that pre-populates data.
Reference: Wire core-crypto patterns
Wire uses the same approach (refinery for native SQL + custom WASM framework):
- 22 SQL migrations (V1__schema.sql through V22__unhex_id_columns.sql)
- "Meta-migrations" for complex Rust data transforms between SQL steps
- IDB migrations applied one-at-a-time with version stepping
- Builder chaining for IDB structural changes
Our system is simpler (single KV table vs 18+ entity tables) but architecturally equivalent.