diff --git a/lattice-cli/src/node.rs b/lattice-cli/src/node.rs index 6c58402..9ea34c2 100644 --- a/lattice-cli/src/node.rs +++ b/lattice-cli/src/node.rs @@ -383,4 +383,35 @@ mod tests { let _ = std::fs::remove_dir_all(data_dir.base()); } + + #[test] + fn test_idempotent_put_and_delete() { + let data_dir = temp_data_dir("idempotent"); + + let (node, _) = LatticeNodeBuilder { data_dir: data_dir.clone() } + .build() + .expect("create node"); + let store_id = node.init().expect("init"); + let (store, _) = node.open_store(store_id).expect("open store"); + + // Put twice with same value - second should be idempotent + let seq1 = store.put(b"/key", b"value").expect("put 1"); + assert_eq!(seq1, 1); + + let seq2 = store.put(b"/key", b"value").expect("put 2"); + assert_eq!(seq2, 1, "Second put with same value should be idempotent (no new entry)"); + + assert_eq!(store.log_seq(), 1, "Log should have 1 entry, not 2"); + + // Delete twice - second should be idempotent + let seq3 = store.delete(b"/key").expect("delete 1"); + assert_eq!(seq3, 2); + + let seq4 = store.delete(b"/key").expect("delete 2"); + assert_eq!(seq4, 2, "Second delete should be idempotent (no new entry)"); + + assert_eq!(store.log_seq(), 2, "Log should have 2 entries, not 3"); + + let _ = std::fs::remove_dir_all(data_dir.base()); + } } diff --git a/lattice-cli/src/store_actor.rs b/lattice-cli/src/store_actor.rs index af5d49e..6533033 100644 --- a/lattice-cli/src/store_actor.rs +++ b/lattice-cli/src/store_actor.rs @@ -145,12 +145,24 @@ impl StoreActor { fn do_put(&mut self, key: &[u8], value: &[u8]) -> Result { let heads = self.store.get_heads(key)?; + + // Idempotency check (pure function) + if !Store::needs_put(&heads, value) { + return Ok(self.sigchain.len()); // Idempotent, no new entry + } + let parent_hashes: Vec> = heads.iter().map(|h| h.hash.clone()).collect(); self.commit_entry(parent_hashes, |b| b.put(key.to_vec(), value.to_vec())) } fn do_delete(&mut self, key: &[u8]) -> Result { let heads = self.store.get_heads(key)?; + + // Idempotency check (pure function) + if !Store::needs_delete(&heads) { + return Ok(self.sigchain.len()); // Idempotent, no new entry + } + let parent_hashes: Vec> = heads.iter().map(|h| h.hash.clone()).collect(); self.commit_entry(parent_hashes, |b| b.delete(key.to_vec())) } diff --git a/lattice-core/src/store.rs b/lattice-core/src/store.rs index 7618d9e..fea574a 100644 --- a/lattice-core/src/store.rs +++ b/lattice-core/src/store.rs @@ -237,6 +237,23 @@ impl Store { } Ok(result) } + /// Check if a put operation is needed given current heads + /// Returns false if the winning head has the same value (idempotent) + pub fn needs_put(heads: &[HeadInfo], value: &[u8]) -> bool { + match Self::pick_winner(heads) { + Some(winner) => winner.value != value, // Skip if winner already has value + None => true, // No heads = need put + } + } + + /// Check if a delete operation is needed given current heads + /// Returns false if no heads or winning head is already a tombstone (idempotent) + pub fn needs_delete(heads: &[HeadInfo]) -> bool { + match Self::pick_winner(heads) { + Some(winner) => !winner.tombstone, // Skip if winner is already tombstone + None => false, // No heads = nothing to delete + } + } /// Get author state for a specific author pub fn author_state(&self, author: &[u8; 32]) -> Result, StoreError> { @@ -952,4 +969,94 @@ mod tests { let _ = std::fs::remove_file(&backup_path); let _ = std::fs::remove_file(&log_path); } + + #[test] + fn test_needs_put_empty_heads() { + // No heads = need put + let heads: Vec = vec![]; + assert!(Store::needs_put(&heads, b"value")); + } + + #[test] + fn test_needs_put_same_value() { + // Single head with same value = idempotent, no put needed + let heads = vec![HeadInfo { + value: b"hello".to_vec(), + hlc: 1000, + author: [1u8; 32].to_vec(), + hash: [2u8; 32].to_vec(), + tombstone: false, + }]; + assert!(!Store::needs_put(&heads, b"hello")); + } + + #[test] + fn test_needs_put_different_value() { + // Single head with different value = need put + let heads = vec![HeadInfo { + value: b"hello".to_vec(), + hlc: 1000, + author: [1u8; 32].to_vec(), + hash: [2u8; 32].to_vec(), + tombstone: false, + }]; + assert!(Store::needs_put(&heads, b"world")); + } + + #[test] + fn test_needs_put_multiple_heads_winner_matches() { + // Multiple heads where WINNER has our value = idempotent + // Winner is highest HLC (1001), value "v2" + let heads = vec![ + HeadInfo { + value: b"v1".to_vec(), + hlc: 1000, + author: [1u8; 32].to_vec(), + hash: [2u8; 32].to_vec(), + tombstone: false, + }, + HeadInfo { + value: b"v2".to_vec(), + hlc: 1001, // Winner (highest HLC) + author: [3u8; 32].to_vec(), + hash: [4u8; 32].to_vec(), + tombstone: false, + }, + ]; + assert!(!Store::needs_put(&heads, b"v2")); // Winner has value = skip + assert!(Store::needs_put(&heads, b"v1")); // Winner doesn't have value = put + } + + #[test] + fn test_needs_delete_empty_heads() { + // No heads = idempotent, no delete needed + let heads: Vec = vec![]; + assert!(!Store::needs_delete(&heads)); + } + + #[test] + fn test_needs_delete_with_heads() { + // Has non-tombstone heads = need delete + let heads = vec![HeadInfo { + value: b"data".to_vec(), + hlc: 1000, + author: [1u8; 32].to_vec(), + hash: [2u8; 32].to_vec(), + tombstone: false, + }]; + assert!(Store::needs_delete(&heads)); + } + + #[test] + fn test_needs_delete_tombstone_is_winner() { + // Winning head is already tombstone = no delete needed + let heads = vec![HeadInfo { + value: vec![], + hlc: 1000, + author: [1u8; 32].to_vec(), + hash: [2u8; 32].to_vec(), + tombstone: true, + }]; + assert!(!Store::needs_delete(&heads)); + } }