gradatum-vault 0.4.3

Multi-vault registry + lifecycle (create/list/swap/delete) + forward-compat
Documentation
//! F-41 — Optimistic locking.
//!
//! `write_if_match` compare le `sha256` attendu au hash courant de la note AVANT
//! d'écrire via `write_note_inner`. Si le hash ne correspond pas → retourne
//! `WriteResult::Conflict { current_sha256 }` sans écrire.
//!
//! ## Contrat
//!
//! - `expected_sha256 = None` → écriture inconditionnelle (rétrocompat, comportement actuel).
//! - `expected_sha256 = Some(h)` ET note absente → écriture (la note est nouvelle).
//! - `expected_sha256 = Some(h)` ET note présente ET `h == current` → écriture.
//! - `expected_sha256 = Some(h)` ET note présente ET `h != current` → Conflict.
//!
//! ## Flux async
//!
//! Le client soumet `expected_sha256` dans la requête `POST /api/v1/vault_write`.
//! Le handler (`gradatum-server`) porte la valeur dans `CurateSpec.expected_sha256`.
//! Le worker (`handle_curate`) appelle `write_if_match` avec le hash attendu.
//! Sur `Conflict` → le job est marqué terminal avec `JobStatus::Conflict` (lisible
//! via `GET /api/v1/jobs/{id}`). PAS de 409 HTTP synchrone (écriture asynchrone).

use crate::{error::VaultError, registry::Vault};
use gradatum_core::{frontmatter::Frontmatter, identity::NoteId};

/// Résultat d'un `write_if_match`.
///
/// - `Written` : la note a été écrite (hash après écriture).
/// - `Conflict` : la note n'a PAS été écrite — le hash courant est fourni pour
///   permettre au client de résoudre le conflit (3-way merge ou abandon).
#[derive(Debug, PartialEq)]
pub enum WriteResult {
    /// L'écriture a réussi.
    Written {
        /// Hash SHA-256 de la note après écriture.
        new_sha256: [u8; 32],
    },
    /// Conflit optimistic-lock — la note n'a PAS été écrite.
    Conflict {
        /// Hash SHA-256 courant (la version qui a gagné la course concurrente).
        current_sha256: [u8; 32],
    },
}

impl Vault {
    /// Écrit une note avec vérification optionnelle du hash courant (F-41).
    ///
    /// ## Paramètres
    ///
    /// - `frontmatter` : frontmatter de la nouvelle version.
    /// - `body` : corps Markdown de la nouvelle version.
    /// - `id` : identifiant ULID préalloué (honoré via `write_note_inner`).
    /// - `expected_sha256` : hash attendu (`None` = inconditionnel).
    ///
    /// ## Comportement
    ///
    /// Voir la doc du module pour la table de vérité complète.
    ///
    /// ## Erreurs
    ///
    /// - `VaultError::Core(NoteNotFound)` ne peut pas survenir ici (géré par le read-before-write).
    /// - `VaultError::Storage` / `VaultError::Markdown` : erreurs d'I/O.
    /// - `VaultError::Conflict` n'est jamais retourné comme `Err` — il est dans `WriteResult`.
    pub async fn write_if_match(
        &self,
        frontmatter: Frontmatter,
        body: String,
        id: NoteId,
        expected_sha256: Option<[u8; 32]>,
    ) -> Result<WriteResult, VaultError> {
        if let Some(expected) = expected_sha256 {
            // Single-worker invariant: TOCTOU non-issue as gradatum-worker runs
            // single-instance (un seul goroutine `handle_curate` actif à la fois
            // par déploiement). La fenêtre read_note→write_note_inner ne peut être
            // interrompue par un autre write_if_match concurrent.
            // Atomic upsert (SELECT+UPDATE sous le même rusqlite Mutex) deferred —
            // requis uniquement si multi-worker devient nécessaire (v0.5+, F-41 phase 2).
            //
            // Lire la version existante pour comparer les hashes.
            // NoteNotFound → note nouvelle → pas de conflit possible → écriture directe.
            match self.read_note(id).await {
                Ok(existing) => {
                    let current = existing.content_hash.0;
                    if current != expected {
                        // Conflit : le hash attendu est périmé.
                        return Ok(WriteResult::Conflict {
                            current_sha256: current,
                        });
                    }
                    // Hash courant == attendu → on peut écrire en toute sécurité.
                }
                Err(VaultError::Core(gradatum_core::error::GradatumError::NoteNotFound(_))) => {
                    // Note nouvelle — pas de conflit possible.
                }
                Err(other) => return Err(other),
            }
        }
        // Écriture effective — délègue au chemin commun (read-before-write inclus).
        let written = self.write_note_inner(frontmatter, body, id).await?;
        Ok(WriteResult::Written {
            new_sha256: written.content_hash.0,
        })
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Utc;
    use gradatum_core::frontmatter::Frontmatter;
    use gradatum_core::scope::VaultId;
    use gradatum_core::section::Section;
    use gradatum_core::status::NoteStatus;
    use tempfile::TempDir;

    /// Construit un Frontmatter minimal valide pour les tests.
    fn minimal_fm() -> Frontmatter {
        Frontmatter {
            schema_version: 1,
            vault_id: VaultId::new("main"),
            locus: None,
            section: Section::Decisions,
            status: NoteStatus::Draft,
            status_reason: None,
            status_changed: None,
            tags: Default::default(),
            author: None,
            created: Utc::now(),
            updated: None,
            extra: Default::default(),
            provenance: None,
            forgotten: None,
            forgotten_at: None,
            forgotten_by: None,
        }
    }

    /// expected_sha256 = None → écriture inconditionnelle.
    #[tokio::test]
    async fn write_if_match_none_is_unconditional() {
        let dir = TempDir::new().unwrap();
        let vault = Vault::create(dir.path(), VaultId::new("main"))
            .await
            .unwrap();
        let id = NoteId::new();
        let result = vault
            .write_if_match(minimal_fm(), "corps".into(), id, None)
            .await
            .unwrap();
        assert!(
            matches!(result, WriteResult::Written { .. }),
            "None doit retourner Written"
        );
    }

    /// Hash courant correct → écriture réussie.
    #[tokio::test]
    async fn write_if_match_correct_hash_writes() {
        let dir = TempDir::new().unwrap();
        let vault = Vault::create(dir.path(), VaultId::new("main"))
            .await
            .unwrap();
        let id = NoteId::new();
        // Premier write pour obtenir le hash courant.
        let r1 = vault
            .write_if_match(minimal_fm(), "v1".into(), id, None)
            .await
            .unwrap();
        let WriteResult::Written {
            new_sha256: hash_v1,
        } = r1
        else {
            panic!("premier write doit retourner Written");
        };
        // Deuxième write avec le bon hash → doit réussir.
        let r2 = vault
            .write_if_match(minimal_fm(), "v2".into(), id, Some(hash_v1))
            .await
            .unwrap();
        assert!(
            matches!(r2, WriteResult::Written { .. }),
            "hash courant correct doit retourner Written"
        );
    }

    /// Hash périmé → Conflict, note non écrasée.
    #[tokio::test]
    async fn write_if_match_conflict_on_stale_hash() {
        let dir = TempDir::new().unwrap();
        let vault = Vault::create(dir.path(), VaultId::new("main"))
            .await
            .unwrap();
        let id = NoteId::new();
        // Premier write → hash v1.
        let r1 = vault
            .write_if_match(minimal_fm(), "v1".into(), id, None)
            .await
            .unwrap();
        let WriteResult::Written {
            new_sha256: hash_v1,
        } = r1
        else {
            panic!("premier write doit retourner Written");
        };
        // Écriture concurrente change le hash courant.
        vault
            .write_if_match(minimal_fm(), "v2".into(), id, None)
            .await
            .unwrap();
        // Tentative avec l'ancien hash → Conflict.
        let r3 = vault
            .write_if_match(minimal_fm(), "v3".into(), id, Some(hash_v1))
            .await
            .unwrap();
        match r3 {
            WriteResult::Conflict { current_sha256 } => {
                // current_sha256 doit être le hash de v2, pas de v1.
                assert_ne!(
                    current_sha256, hash_v1,
                    "current_sha256 sur Conflict doit être celui de v2"
                );
            }
            WriteResult::Written { .. } => panic!("hash périmé doit retourner Conflict"),
        }
        // La note ne doit PAS avoir été écrasée par v3.
        let note = vault.read_note(id).await.unwrap();
        assert_eq!(
            note.body.markdown, "v2",
            "note ne doit pas être écrasée par v3 sur Conflict"
        );
    }

    /// Note nouvelle + expected_sha256 Some → écriture (pas de conflit possible).
    #[tokio::test]
    async fn write_if_match_new_note_with_expected_writes() {
        let dir = TempDir::new().unwrap();
        let vault = Vault::create(dir.path(), VaultId::new("main"))
            .await
            .unwrap();
        let id = NoteId::new();
        // Hash arbitraire — la note n'existe pas encore.
        let arbitrary_hash = [0u8; 32];
        let result = vault
            .write_if_match(minimal_fm(), "nouveau".into(), id, Some(arbitrary_hash))
            .await
            .unwrap();
        assert!(
            matches!(result, WriteResult::Written { .. }),
            "note nouvelle avec expected doit retourner Written"
        );
    }
}