Zum Inhalt

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:

### [F-0032 Missing closing bracket
- **Status**: InvalidValue
- **Priorität** (Missing colon)

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)

  1. ✅ P-004: Parse vor Delete
  2. ✅ P-002: Transaktionales rebuild_database

Wichtig (bald)

  1. ✅ 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
}

Siehe auch