Datenverlust-Risiken (P-002, P-003, P-004)¶
Dieses Dokument beschreibt kritische Szenarien, die zu Datenverlust führen können.
P-002: rebuild_database ohne Rollback¶
Problem¶
Datei: src-tauri/src/commands/project.rs:459-462
pub fn rebuild_database_from_backlog(
conn: &mut Connection,
project_id: &str,
items: Vec<BacklogItem>,
) -> Result<(), String> {
// ⚠️ KRITISCH: DELETE vor INSERT
conn.execute(
"DELETE FROM backlog_items WHERE project_id = ?",
params![project_id]
)?;
conn.execute(
"DELETE FROM dependencies WHERE project_id = ?",
params![project_id]
)?;
conn.execute(
"DELETE FROM acceptance_criteria WHERE project_id = ?",
params![project_id]
)?;
// Wenn ab hier Fehler → Alle Daten weg, kein Rollback!
for item in items {
items_repo::insert(conn, project_id, &item)?;
}
for dep in dependencies {
deps_repo::insert(conn, project_id, &dep)?;
}
Ok(())
}
Szenario: Parse-Fehler nach DELETE¶
1. External Change erkannt (Claude hat Backlog.md geändert)
2. rebuild_database() wird aufgerufen
3. DELETE FROM backlog_items → ✓ (alle 85 Items gelöscht)
4. DELETE FROM dependencies → ✓ (alle Dependencies gelöscht)
5. DELETE FROM acceptance_criteria → ✓ (alle Kriterien gelöscht)
6. Beginne INSERT für Item F-0001 → ✓
7. Beginne INSERT für Item F-0002 → ✗ FEHLER (z.B. Constraint-Violation)
8. Funktion bricht ab mit Error
→ Ergebnis: DB fast leer (nur F-0001 inserted), alle anderen 84 Items VERLOREN
User-Impact: - Öffnet App: Sieht nur noch 1 Item statt 85 - Ungespeicherte Änderungen: Komplett weg - Backup: Wenn vorhanden, muss manuell restored werden
Ursache¶
Kein Transaction-Wrapping: DELETEs und INSERTs sind nicht atomar.
SQLite unterstützt Transactions:
conn.execute("BEGIN TRANSACTION", [])?;
// ... operations
conn.execute("COMMIT", [])?;
// Bei Fehler: ROLLBACK
Aber aktueller Code nutzt das nicht!
Lösung (Konzept)¶
Variante 1: Transaction mit Rollback
pub fn rebuild_database_from_backlog(
conn: &mut Connection,
project_id: &str,
items: Vec<BacklogItem>,
) -> Result<(), String> {
// Start Transaction
let tx = conn.transaction()
.map_err(|e| format!("Failed to start transaction: {}", e))?;
// Delete in Transaction
tx.execute("DELETE FROM backlog_items WHERE project_id = ?", params![project_id])?;
tx.execute("DELETE FROM dependencies WHERE project_id = ?", params![project_id])?;
tx.execute("DELETE FROM acceptance_criteria WHERE project_id = ?", params![project_id])?;
// Insert in Transaction
for item in items {
items_repo::insert_tx(&tx, project_id, &item)?;
// Bei Fehler: Automatic Rollback via Drop
}
// Commit only if all succeeded
tx.commit()
.map_err(|e| format!("Failed to commit: {}", e))?;
Ok(())
}
Variante 2: Temp Table Backup
pub fn rebuild_database_from_backlog(
conn: &mut Connection,
project_id: &str,
items: Vec<BacklogItem>,
) -> Result<(), String> {
let tx = conn.transaction()?;
// 1. Backup to temp table
tx.execute(
"CREATE TEMP TABLE backup_items AS SELECT * FROM backlog_items WHERE project_id = ?",
params![project_id]
)?;
// 2. Delete
tx.execute("DELETE FROM backlog_items WHERE project_id = ?", params![project_id])?;
// 3. Insert new data
for item in items {
if let Err(e) = items_repo::insert_tx(&tx, project_id, &item) {
// Restore from backup
tx.execute(
"INSERT INTO backlog_items SELECT * FROM backup_items",
[]
)?;
return Err(format!("Insert failed, restored from backup: {}", e));
}
}
// 4. Commit (drop temp table automatically)
tx.commit()?;
Ok(())
}
P-003: Hash nach Write gespeichert¶
Problem¶
Datei: src-tauri/src/commands/items.rs:395-400
pub async fn save_backlog(
project_id: String,
app_state: State<'_, AppState>,
) -> Result<(), String> {
// ... render content ...
// 1. Write file
std::fs::write(&backlog_path, &content)
.map_err(|e| format!("Failed to write: {}", e))?;
// 2. Calculate hash (AFTER write)
let hash = calculate_hash(&content);
// 3. Store hash
store_hash(&mut conn, &project_id, &hash)?;
Ok(())
}
Szenario: Crash zwischen Write und Hash-Store¶
t=0ms: write(&backlog_path, content) → ✓ File geschrieben
t=5ms: App crashes (z.B. Power Loss, OS Kill)
→ Hash nie gespeichert in DB
Beim nächsten App-Start:
- Backlog.md hat neuen Inhalt (von t=0ms)
- DB hat alten Hash (von vor dem Write)
- FileWatcher vergleicht: Mismatch → "External Change"
- Konflikt-Banner erscheint (obwohl keine echte External Change)
User-Impact: - Permanenter Konflikt-Zustand - Bei jedem App-Start: Konflikt-Banner - User muss manuell "Neu laden" wählen - Verwirrend und nervig
Ursache¶
Window zwischen Write und Hash-Store: In diesem Window kann App crashen.
Lösung (Konzept)¶
Hash VOR Write berechnen:
pub async fn save_backlog(
project_id: String,
app_state: State<'_, AppState>,
) -> Result<(), String> {
// ... render content ...
// 1. Calculate hash FIRST (before any I/O)
let hash = calculate_hash(&content);
// 2. Write file
std::fs::write(&backlog_path, &content)?;
// 3. Store hash (already calculated)
store_hash(&mut conn, &project_id, &hash)?;
Ok(())
}
Vorteil: - Hash wird vom Content berechnet, der geschrieben werden SOLL - Wenn Write erfolgreich, dann stimmt Hash garantiert - Wenn Crash zwischen (2) und (3): Hash ist falsch, aber beim nächsten Start wird (2) nicht wiederholt → nächster Save fixt Hash
Noch besser: Transaction für DB-Write:
// Wrap store_hash in Transaction
let tx = conn.transaction()?;
store_hash_tx(&tx, &project_id, &hash)?;
tx.commit()?;
P-004: Parse-Fehler Risiko¶
Problem¶
rebuild_database löscht Items BEVOR Parser validiert, dass neue Items valide sind.
Sequenz:
1. DELETE FROM backlog_items (alte Items weg)
2. Parse Backlog.md (kann fehlschlagen!)
3. INSERT INTO backlog_items (wird nie erreicht bei Parse-Fehler)
Szenario: Invalide Backlog.md¶
Ursache: Claude Code schreibt fehlerhafte Syntax:
Ablauf:
1. FileWatcher erkennt Änderung
2. App ruft rebuild_database()
3. DELETE FROM backlog_items → ✓ (alle Items gelöscht)
4. parser::parse_backlog() → ✗ FEHLER (Invalid syntax)
5. Funktion bricht ab
→ Ergebnis: DB komplett leer!
User-Impact: - Öffnet App: "Keine Items gefunden" - Alle Daten weg (bis auf Backup, falls vorhanden) - Panik!
Lösung (Konzept)¶
Parse ZUERST, dann DELETE:
pub fn rebuild_database_from_backlog(
conn: &mut Connection,
project_id: &str,
backlog_content: &str,
) -> Result<(), String> {
// 1. Parse FIRST (before touching DB)
let parse_result = parser::parse_backlog(backlog_content)?;
// Wenn hier Fehler → Function returnt, DB unverändert ✓
// 2. Validate
validator::validate_items(&parse_result.items)?;
validator::check_cycles(&parse_result.dependencies)?;
// Wenn hier Fehler → Function returnt, DB unverändert ✓
// 3. Erst JETZT DB ändern (in Transaction)
let tx = conn.transaction()?;
tx.execute("DELETE FROM backlog_items WHERE project_id = ?", params![project_id])?;
for item in parse_result.items {
items_repo::insert_tx(&tx, project_id, &item)?;
}
tx.commit()?;
Ok(())
}
Vorteile: - ✅ Parse-Fehler → DB bleibt unverändert - ✅ Validation-Fehler → DB bleibt unverändert - ✅ Nur bei vollständig validen Daten wird DB geändert - ✅ Transaction garantiert Atomarität
Impact-Zusammenfassung¶
| Problem | Wahrscheinlichkeit | Datenverlust | Wiederherstellbar? |
|---|---|---|---|
| rebuild ohne Rollback | Mittel | Kritisch | Nur via Backup |
| Hash-Timing | Niedrig | Keiner | Automatisch (next save) |
| Parse-Fehler | Mittel | Kritisch | Nur via Backup |
Priorisierung¶
Kritisch (sofort)¶
- ✅ P-004: Parse vor Delete
- ✅ P-002: Transaktionales rebuild_database
Wichtig (bald)¶
- ✅ P-003: Hash vor Write
Test-Strategie¶
Unit-Tests¶
#[test]
fn test_rebuild_rollback_on_error() {
let mut conn = setup_test_db();
let project_id = "test-project";
// Insert initial items
insert_test_items(&mut conn, project_id, 10);
// Try rebuild with invalid data
let invalid_items = vec![/* item with constraint violation */];
let result = rebuild_database(&mut conn, project_id, invalid_items);
// Verify: Rollback happened, old items still present
assert!(result.is_err());
let count: i32 = conn.query_row(
"SELECT COUNT(*) FROM backlog_items WHERE project_id = ?",
params![project_id],
|row| row.get(0)
).unwrap();
assert_eq!(count, 10); // Original 10 items still there
}
#[test]
fn test_parse_before_delete() {
let mut conn = setup_test_db();
insert_test_items(&mut conn, "proj", 10);
// Invalid markdown syntax
let invalid_md = "### [F-001 Missing bracket\nInvalid: content";
let result = rebuild_database_from_backlog(&mut conn, "proj", invalid_md);
// Verify: Parse error, DB unchanged
assert!(result.is_err());
assert!(result.unwrap_err().contains("Parse error"));
let count: i32 = conn.query_row(
"SELECT COUNT(*) FROM backlog_items WHERE project_id = ?",
params!["proj"],
|row| row.get(0)
).unwrap();
assert_eq!(count, 10); // All items still present
}