SQL Migration Column Evolution — Silent Data Loss on INSERT
SQL Migration Column Evolution — Silent Data Loss on INSERT
Problem
V010 migration added an area column to the blocks table to support layout grid cell assignments (INK-235). However,
the insert_blocks() function in the SqlitePageRepository was not updated to include area in its INSERT statement.
Because SQLite silently defaults missing columns to NULL, every block insertion quietly discarded the area value — a
data loss bug with no error signal.
Symptoms:
- Blocks are inserted and read back successfully, but the
areafield is always NULL - Layout assignments appear to work in the UI (set in memory) but are lost after page reload
- No runtime errors, no warnings, no failed queries — completely silent
Investigation
Steps Tried
- Layout area assignments worked in memory and passed through the Tauri command layer correctly
- After save and reload, all area assignments reverted to NULL
- Inspected the SQLite database directly — confirmed
areacolumn existed (migration ran) but all values were NULL - Traced the write path from
assign_block_areause case throughupdate_pagetoinsert_blocks() - Found the INSERT statement was missing the
areacolumn entirely
Root Cause
Schema migrations and repository SQL statements evolve independently. When a migration adds a column to a table, there is no compile-time enforcement that all repository write paths (INSERT, UPDATE, UPSERT) are updated to include the new column. SQLite’s behavior compounds this:
- SQLite silently defaults any missing column to its DEFAULT value (typically NULL)
- The INSERT succeeds with no error
- The application reads back the row and gets NULL for the missing field
- If the domain struct defaults to
Noneor an empty value, the bug is invisible at runtime
This creates a class of bug that is:
- Silent — no errors at any layer
- Intermittent in appearance — data seems to persist until the process restarts
- Hard to detect — requires a roundtrip test that writes and re-reads the specific field
The Gap
Migration V010 Repository Code Domain Entity───────────── ─────────────── ─────────────ALTER TABLE blocks INSERT INTO blocks struct Block {ADD COLUMN area (id, page_id, ... area: Option<String>TEXT; content_type_metadata) } VALUES (...) // area NOT listed ──────── area set correctly but never writtenThe Rust compiler ensures the domain entity has the field, and the migration ensures the column exists, but nothing ensures the SQL string in the repository matches both.
Solution
Update the INSERT statement to include the new column:
// Before (data loss — area silently dropped)"INSERT INTO blocks (id, page_id, slot_id, content, content_loro, content_loro_version, content_type, content_type_metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
// After (correct — area preserved)"INSERT INTO blocks (id, page_id, slot_id, content, content_loro, content_loro_version, content_type, content_type_metadata, area) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"// with params![..., block.area]Verification
Add a roundtrip test that exercises the new column through the full write/read path:
#[test]fn test_insert_block_with_area_roundtrips() { // Create a block with area = Some("main") // Insert via repository // Read back via repository // Assert area == Some("main")}Prevention
Mandatory Audit After Column Addition
After adding any column via migration, perform these five checks before considering the work complete:
-
Search all INSERT statements for the affected table
Terminal window grep -rn "INSERT INTO blocks" crates/infrastructure/ -
Search all UPDATE statements for the affected table
Terminal window grep -rn "UPDATE blocks" crates/infrastructure/ -
Search the domain entity for the corresponding field
Terminal window grep -n "area" crates/domain/src/block.rs -
Verify the field appears in all write paths — every INSERT, UPDATE, and UPSERT that touches the table must include the new column
-
Add a roundtrip test that writes the new field and reads it back, asserting the value survives the trip
Migration Annotation Convention
When writing a migration that adds a column, add a comment to the migration file noting which repository methods need updating:
-- Migration V010: Add layout support to pages and blocks---- REPOSITORY AUDIT REQUIRED:-- - pages: insert_page(), update_page() in page_repository.rs-- - blocks: insert_blocks(), update_block() in page_repository.rs--ALTER TABLE pages ADD COLUMN layout TEXT;ALTER TABLE blocks ADD COLUMN area TEXT;Warning Signs
- Any migration that adds a column to a table with existing repository write paths
- INSERT statements that use positional parameters (
?1, ?2, ...) — easy to miss a new column - Domain structs with
Option<T>fields that default toNone— NULL reads back as a valid value, hiding the bug - No roundtrip test for a newly added field
Why the Compiler Cannot Help
Rust’s type system enforces that block.area exists on the struct, but the SQL statement is a raw string. The compiler
has no way to verify that the string mentions all fields of the struct. This is a fundamental limitation of raw SQL in
any language. Solutions like compile-time SQL checking (e.g., sqlx::query!) can catch some of these issues, but they
require a different infrastructure setup and are not used in this codebase.
References
- Discovery: INK-235 deep review (layout definition and rendering)
- File:
crates/infrastructure/sqlite/src/workspace/page/write.rs(insert_blocksfunction) - Migration:
crates/infrastructure/sqlite/migrations/workspace/V010__add_layout_to_pages.sql
Prevention Strategies: FK Constraint Import Failure Next
SQLite GROUP BY/HAVING on Non-Aggregate Columns Is Undefined
Was this page helpful?
Thanks for your feedback!